diff --git a/js/input.js b/js/input.js new file mode 100644 index 0000000..67c1c4a --- /dev/null +++ b/js/input.js @@ -0,0 +1,233 @@ +// Input Handling +const Input = { + currentTool: 'select', + rotation: 0, + mouseScreen: { x: 0, y: 0 }, + mouseWorld: { x: 0, y: 0 }, + isDragging: false, + lastMouse: { x: 0, y: 0 }, + isMouseDown: false, + + // Initialize input handlers + init() { + const canvas = Renderer.canvas; + + canvas.addEventListener('mousedown', (e) => this.onMouseDown(e)); + canvas.addEventListener('mousemove', (e) => this.onMouseMove(e)); + canvas.addEventListener('mouseup', (e) => this.onMouseUp(e)); + canvas.addEventListener('mouseleave', () => this.onMouseLeave()); + canvas.addEventListener('wheel', (e) => this.onWheel(e)); + canvas.addEventListener('contextmenu', (e) => e.preventDefault()); + document.addEventListener('keydown', (e) => this.onKeyDown(e)); + }, + + // Mouse down handler + onMouseDown(e) { + const rect = Renderer.canvas.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + + // Right click or middle click to drag + if (e.button === 2 || e.button === 1) { + this.isDragging = true; + this.lastMouse = { x: e.clientX, y: e.clientY }; + Renderer.canvas.style.cursor = 'grabbing'; + return; + } + + this.isMouseDown = true; + const world = Utils.screenToWorld(mx, my, Renderer.camera); + + if (this.currentTool === 'select') { + const building = Buildings.getAt(world.x, world.y); + if (building && building.type === 'assembler') { + game.ui.showRecipeSelect(e.clientX, e.clientY, building); + } + } else if (this.currentTool === 'delete') { + Buildings.remove(world.x, world.y); + Towers.remove(world.x, world.y); + } else if (this.currentTool === 'mine') { + // Start manual mining + Simulation.startMining(world.x, world.y); + } else if (Towers.TYPES[this.currentTool]) { + // Place tower + Towers.place(this.currentTool, world.x, world.y); + } else { + Buildings.place(this.currentTool, world.x, world.y, this.rotation); + } + }, + + // Mouse move handler + onMouseMove(e) { + const rect = Renderer.canvas.getBoundingClientRect(); + this.mouseScreen.x = e.clientX - rect.left; + this.mouseScreen.y = e.clientY - rect.top; + this.mouseWorld = Utils.screenToWorld(this.mouseScreen.x, this.mouseScreen.y, Renderer.camera); + + if (this.isDragging) { + Renderer.camera.x -= (e.clientX - this.lastMouse.x); + Renderer.camera.y -= (e.clientY - this.lastMouse.y); + this.lastMouse = { x: e.clientX, y: e.clientY }; + return; + } + + // Continue mining if holding mouse and using mine tool + if (this.isMouseDown && this.currentTool === 'mine') { + if (!Simulation.mining.active || + Simulation.mining.x !== this.mouseWorld.x || + Simulation.mining.y !== this.mouseWorld.y) { + Simulation.startMining(this.mouseWorld.x, this.mouseWorld.y); + } + } + + // Update tooltip + game.ui.updateTooltip(e.clientX, e.clientY, this.mouseWorld); + }, + + // Mouse up handler + onMouseUp(e) { + this.isDragging = false; + this.isMouseDown = false; + Renderer.canvas.style.cursor = 'crosshair'; + + // Stop mining when releasing mouse + if (this.currentTool === 'mine') { + Simulation.stopMining(); + } + }, + + // Mouse leave handler + onMouseLeave() { + this.isDragging = false; + this.isMouseDown = false; + if (this.currentTool === 'mine') { + Simulation.stopMining(); + } + }, + + // Mouse wheel handler + onWheel(e) { + e.preventDefault(); + const oldZoom = Renderer.camera.zoom; + const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; + Renderer.camera.zoom = Utils.clamp(Renderer.camera.zoom * zoomFactor, 0.4, 2.5); + + // Zoom toward mouse position + const worldX = (this.mouseScreen.x + Renderer.camera.x) / oldZoom; + const worldY = (this.mouseScreen.y + Renderer.camera.y) / oldZoom; + Renderer.camera.x = worldX * Renderer.camera.zoom - this.mouseScreen.x; + Renderer.camera.y = worldY * Renderer.camera.zoom - this.mouseScreen.y; + }, + + // Key down handler + onKeyDown(e) { + const key = e.key.toLowerCase(); + + switch (key) { + case 'r': + this.rotation = (this.rotation + 1) % 4; + break; + case 'q': + this.selectTool('select'); + break; + case 'x': + this.selectTool('delete'); + break; + case 'e': + this.selectTool('mine'); + break; + case '1': + this.selectTool('miner'); + break; + case '2': + this.selectTool('belt'); + break; + case '3': + this.selectTool('inserter'); + break; + case '4': + this.selectTool('chest'); + break; + case '5': + this.selectTool('furnace'); + break; + case '6': + this.selectTool('assembler'); + break; + case '7': + this.selectTool('gun_turret'); + break; + case '8': + this.selectTool('flame_turret'); + break; + case '9': + this.selectTool('laser_turret'); + break; + case '0': + this.selectTool('tesla_turret'); + break; + case '-': + this.selectTool('cannon_turret'); + break; + case '`': + case '~': + // Toggle dev mode + CONFIG.DEV_MODE = !CONFIG.DEV_MODE; + game.ui.showDevModeNotification(); + Audio.playDevMode(); + break; + case 'f1': + // Dev: Spawn wave immediately + if (CONFIG.DEV_MODE) { + e.preventDefault(); + Enemies.waveTimer = 0; + } + break; + case 'f2': + // Dev: Kill all enemies + if (CONFIG.DEV_MODE) { + e.preventDefault(); + Enemies.list = []; + Enemies.enemiesRemaining = 0; + Enemies.spawnQueue = []; + } + break; + case 'f3': + // Dev: Add resources + if (CONFIG.DEV_MODE) { + e.preventDefault(); + Resources.add('iron', 100); + Resources.add('copper', 100); + Resources.add('coal', 100); + Resources.add('iron-plate', 100); + Resources.add('copper-plate', 100); + Resources.add('gear', 50); + Resources.add('circuit', 50); + } + break; + case 'w': + case 'arrowup': + Renderer.camera.y -= 50; + break; + case 's': + case 'arrowdown': + Renderer.camera.y += 50; + break; + case 'a': + case 'arrowleft': + Renderer.camera.x -= 50; + break; + case 'd': + case 'arrowright': + Renderer.camera.x += 50; + break; + } + }, + + // Select a tool + selectTool(tool) { + this.currentTool = tool; + Simulation.stopMining(); + game.ui.updateToolButtons(tool); + } +}; diff --git a/js/renderer.js b/js/renderer.js new file mode 100644 index 0000000..b0fddf4 --- /dev/null +++ b/js/renderer.js @@ -0,0 +1,631 @@ +// Rendering System +const Renderer = { + canvas: null, + ctx: null, + camera: { x: 0, y: 0, zoom: 1 }, + + // Initialize renderer + init() { + this.canvas = document.getElementById('game-canvas'); + this.ctx = this.canvas.getContext('2d'); + this.resize(); + window.addEventListener('resize', () => this.resize()); + }, + + // Resize canvas to container + resize() { + const container = this.canvas.parentElement; + this.canvas.width = container.clientWidth; + this.canvas.height = container.clientHeight; + }, + + // Center camera on position + centerCamera(x, y) { + this.camera.x = x * CONFIG.TILE_SIZE * this.camera.zoom - this.canvas.width / 2; + this.camera.y = y * CONFIG.TILE_SIZE * this.camera.zoom - this.canvas.height / 2; + }, + + // Main render function + render(currentTool, rotation, mouseWorld) { + this.clear(); + this.drawTerrain(); + this.drawBuildings(); + this.drawTowers(); + this.drawEnemies(); + this.drawProjectiles(); + this.drawMiningProgress(); + this.drawPlacementPreview(currentTool, rotation, mouseWorld); + this.drawDeletePreview(currentTool, mouseWorld); + }, + + // Clear canvas + clear() { + this.ctx.fillStyle = '#1a2a1a'; + this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + }, + + // Draw terrain and resources + drawTerrain() { + const tileScreenSize = CONFIG.TILE_SIZE * this.camera.zoom; + const startX = Math.floor(this.camera.x / tileScreenSize); + const startY = Math.floor(this.camera.y / tileScreenSize); + const endX = startX + Math.ceil(this.canvas.width / tileScreenSize) + 1; + const endY = startY + Math.ceil(this.canvas.height / tileScreenSize) + 1; + + for (let y = startY; y < endY; y++) { + for (let x = startX; x < endX; x++) { + if (!Utils.inBounds(x, y)) continue; + + const tile = Terrain.getTile(x, y); + const screen = Utils.worldToScreen(x, y, this.camera); + const size = tileScreenSize; + + // Grass with variation + const grassVariant = ((x * 7 + y * 13) % 3); + const grassColors = ['#2d4a2d', '#3a5a3a', '#335533']; + this.ctx.fillStyle = grassColors[grassVariant]; + this.ctx.fillRect(screen.x, screen.y, size, size); + + // Grid lines + this.ctx.strokeStyle = 'rgba(0,0,0,0.2)'; + this.ctx.lineWidth = 1; + this.ctx.strokeRect(screen.x, screen.y, size, size); + + // Draw resources + if (tile.resource && tile.amount > 0) { + this.drawResource(screen.x, screen.y, size, tile.resource, tile.amount); + } + } + } + }, + + // Draw a resource tile + drawResource(screenX, screenY, size, type, amount) { + const padding = size * 0.1; + const richness = Math.min(1, amount / 2000); + const colors = CONFIG.RESOURCE_COLORS[type]; + + // Gradient fill + const grad = this.ctx.createLinearGradient(screenX, screenY, screenX + size, screenY + size); + grad.addColorStop(0, colors.light); + grad.addColorStop(1, colors.dark); + this.ctx.fillStyle = grad; + + Utils.drawRoundedRect(this.ctx, screenX + padding, screenY + padding, size - padding * 2, size - padding * 2, 4 * this.camera.zoom); + this.ctx.fill(); + + // Richness highlight + this.ctx.fillStyle = `rgba(255,255,255,${richness * 0.25})`; + Utils.drawRoundedRect(this.ctx, screenX + padding, screenY + padding, size - padding * 2, size - padding * 2, 4 * this.camera.zoom); + this.ctx.fill(); + + // Texture dots + this.ctx.fillStyle = 'rgba(255,255,255,0.3)'; + for (let i = 0; i < 3; i++) { + const ox = screenX + size * 0.3 + (i % 2) * size * 0.4; + const oy = screenY + size * 0.3 + Math.floor(i / 2) * size * 0.4; + this.ctx.beginPath(); + this.ctx.arc(ox, oy, 2 * this.camera.zoom, 0, Math.PI * 2); + this.ctx.fill(); + } + }, + + // Draw all buildings + drawBuildings() { + const tileScreenSize = CONFIG.TILE_SIZE * this.camera.zoom; + + Buildings.list.forEach(b => { + const screen = Utils.worldToScreen(b.x, b.y, this.camera); + const bSize = Utils.getBuildingSize(b.type); + const w = bSize.w * tileScreenSize; + const h = bSize.h * tileScreenSize; + const pad = 3 * this.camera.zoom; + + const style = CONFIG.BUILDING_STYLES[b.type]; + + // Shadow + this.ctx.fillStyle = 'rgba(0,0,0,0.3)'; + Utils.drawRoundedRect(this.ctx, screen.x + pad + 3, screen.y + pad + 3, w - pad * 2, h - pad * 2, 6 * this.camera.zoom); + this.ctx.fill(); + + // Main body gradient + const grad = this.ctx.createLinearGradient(screen.x, screen.y, screen.x + w, screen.y + h); + grad.addColorStop(0, style.light); + grad.addColorStop(1, style.dark); + this.ctx.fillStyle = grad; + Utils.drawRoundedRect(this.ctx, screen.x + pad, screen.y + pad, w - pad * 2, h - pad * 2, 6 * this.camera.zoom); + this.ctx.fill(); + + // Highlight + this.ctx.fillStyle = 'rgba(255,255,255,0.15)'; + Utils.drawRoundedRect(this.ctx, screen.x + pad, screen.y + pad, w - pad * 2, (h - pad * 2) * 0.4, 6 * this.camera.zoom); + this.ctx.fill(); + + // Border + this.ctx.strokeStyle = 'rgba(0,0,0,0.4)'; + this.ctx.lineWidth = 2 * this.camera.zoom; + Utils.drawRoundedRect(this.ctx, screen.x + pad, screen.y + pad, w - pad * 2, h - pad * 2, 6 * this.camera.zoom); + this.ctx.stroke(); + + // Direction indicator + if (['belt', 'inserter', 'miner'].includes(b.type)) { + this.drawDirectionArrow(screen.x + w / 2, screen.y + h / 2, w, b.rotation); + } + + // Progress bar + if (b.progress > 0 && b.type !== 'belt') { + this.drawProgressBar(screen.x, screen.y, w, h, pad, b.progress); + } + + // Belt items + if (b.type === 'belt') { + const totalItems = Buildings.getTotalInventory(b) + Buildings.getTotalOutput(b); + if (totalItems > 0) { + this.ctx.fillStyle = 'rgba(0,0,0,0.3)'; + this.ctx.beginPath(); + this.ctx.arc(screen.x + w / 2 + 1, screen.y + h / 2 + 1, 5 * this.camera.zoom, 0, Math.PI * 2); + this.ctx.fill(); + + this.ctx.fillStyle = '#ffcc44'; + this.ctx.beginPath(); + this.ctx.arc(screen.x + w / 2, screen.y + h / 2, 5 * this.camera.zoom, 0, Math.PI * 2); + this.ctx.fill(); + } + } + + // Assembler recipe icon + if (b.type === 'assembler' && b.recipe) { + this.ctx.fillStyle = 'rgba(0,0,0,0.5)'; + this.ctx.font = `bold ${16 * this.camera.zoom}px sans-serif`; + this.ctx.textAlign = 'center'; + this.ctx.textBaseline = 'middle'; + this.ctx.fillText(b.recipe === 'gear' ? '⚙' : '⚡', screen.x + w / 2 + 1, screen.y + h / 2 + 1); + this.ctx.fillStyle = '#fff'; + this.ctx.fillText(b.recipe === 'gear' ? '⚙' : '⚡', screen.x + w / 2, screen.y + h / 2); + } + + // Chest contents + if (b.type === 'chest') { + const total = Buildings.getTotalOutput(b); + if (total > 0) { + this.ctx.fillStyle = '#fff'; + this.ctx.font = `bold ${10 * this.camera.zoom}px sans-serif`; + this.ctx.textAlign = 'center'; + this.ctx.textBaseline = 'middle'; + this.ctx.fillText(total > 99 ? '99+' : total, screen.x + w / 2, screen.y + h / 2); + } + } + }); + }, + + // Draw direction arrow + drawDirectionArrow(cx, cy, size, rotation) { + this.ctx.save(); + this.ctx.translate(cx, cy); + this.ctx.rotate(rotation * Math.PI / 2); + + // Shadow + this.ctx.fillStyle = 'rgba(0,0,0,0.3)'; + this.ctx.beginPath(); + this.ctx.moveTo(size * 0.25 + 2, 2); + this.ctx.lineTo(-size * 0.05 + 2, -size * 0.12 + 2); + this.ctx.lineTo(-size * 0.05 + 2, size * 0.12 + 2); + this.ctx.closePath(); + this.ctx.fill(); + + // Arrow + this.ctx.fillStyle = '#fff'; + this.ctx.beginPath(); + this.ctx.moveTo(size * 0.25, 0); + this.ctx.lineTo(-size * 0.05, -size * 0.12); + this.ctx.lineTo(-size * 0.05, size * 0.12); + this.ctx.closePath(); + this.ctx.fill(); + + this.ctx.restore(); + }, + + // Draw progress bar + drawProgressBar(x, y, w, h, pad, progress) { + const barW = w - pad * 4; + const barH = 6 * this.camera.zoom; + const barX = x + pad * 2; + const barY = y + h - pad * 2 - barH; + + this.ctx.fillStyle = 'rgba(0,0,0,0.5)'; + Utils.drawRoundedRect(this.ctx, barX, barY, barW, barH, 2 * this.camera.zoom); + this.ctx.fill(); + + const progGrad = this.ctx.createLinearGradient(barX, barY, barX, barY + barH); + progGrad.addColorStop(0, '#66ff66'); + progGrad.addColorStop(1, '#22aa22'); + this.ctx.fillStyle = progGrad; + Utils.drawRoundedRect(this.ctx, barX + 1, barY + 1, (barW - 2) * progress, barH - 2, 2 * this.camera.zoom); + this.ctx.fill(); + }, + + // Draw manual mining progress + drawMiningProgress() { + if (!Simulation.mining.active) return; + + const screen = Utils.worldToScreen(Simulation.mining.x, Simulation.mining.y, this.camera); + const size = CONFIG.TILE_SIZE * this.camera.zoom; + + // Mining highlight + this.ctx.fillStyle = 'rgba(136, 204, 68, 0.3)'; + this.ctx.fillRect(screen.x, screen.y, size, size); + + // Progress ring + this.ctx.strokeStyle = '#88cc44'; + this.ctx.lineWidth = 4 * this.camera.zoom; + this.ctx.beginPath(); + this.ctx.arc( + screen.x + size / 2, + screen.y + size / 2, + size / 3, + -Math.PI / 2, + -Math.PI / 2 + Simulation.mining.progress * Math.PI * 2 + ); + this.ctx.stroke(); + + // Pickaxe icon + this.ctx.fillStyle = '#fff'; + this.ctx.font = `${14 * this.camera.zoom}px sans-serif`; + this.ctx.textAlign = 'center'; + this.ctx.textBaseline = 'middle'; + this.ctx.fillText('⛏', screen.x + size / 2, screen.y + size / 2); + }, + + // Draw placement preview + drawPlacementPreview(currentTool, rotation, mouseWorld) { + if (currentTool === 'select' || currentTool === 'delete' || currentTool === 'mine') return; + + const tileScreenSize = CONFIG.TILE_SIZE * this.camera.zoom; + const screen = Utils.worldToScreen(mouseWorld.x, mouseWorld.y, this.camera); + + // Check if it's a tower + if (Towers.TYPES[currentTool]) { + const towerType = Towers.TYPES[currentTool]; + const canPlace = Towers.canPlace(currentTool, mouseWorld.x, mouseWorld.y); + + this.ctx.globalAlpha = 0.6; + + // Tower preview circle + this.ctx.fillStyle = canPlace ? 'rgba(100, 255, 100, 0.3)' : 'rgba(255, 100, 100, 0.3)'; + this.ctx.beginPath(); + this.ctx.arc(screen.x + tileScreenSize/2, screen.y + tileScreenSize/2, tileScreenSize/2, 0, Math.PI * 2); + this.ctx.fill(); + + this.ctx.strokeStyle = canPlace ? '#66ff66' : '#ff6666'; + this.ctx.lineWidth = 2; + this.ctx.setLineDash([5, 5]); + this.ctx.beginPath(); + this.ctx.arc(screen.x + tileScreenSize/2, screen.y + tileScreenSize/2, tileScreenSize/2, 0, Math.PI * 2); + this.ctx.stroke(); + + // Range preview + this.ctx.strokeStyle = canPlace ? 'rgba(100, 255, 100, 0.3)' : 'rgba(255, 100, 100, 0.3)'; + this.ctx.beginPath(); + this.ctx.arc(screen.x + tileScreenSize/2, screen.y + tileScreenSize/2, towerType.range * this.camera.zoom, 0, Math.PI * 2); + this.ctx.stroke(); + + this.ctx.setLineDash([]); + this.ctx.globalAlpha = 1; + return; + } + + if (!CONFIG.COSTS[currentTool]) return; + + const previewSize = Utils.getBuildingSize(currentTool); + const w = previewSize.w * tileScreenSize; + const h = previewSize.h * tileScreenSize; + + const canPlace = Buildings.canPlace(currentTool, mouseWorld.x, mouseWorld.y) && + Resources.canAfford(CONFIG.COSTS[currentTool]); + + this.ctx.globalAlpha = 0.6; + this.ctx.fillStyle = canPlace ? 'rgba(100, 255, 100, 0.3)' : 'rgba(255, 100, 100, 0.3)'; + this.ctx.fillRect(screen.x, screen.y, w, h); + + this.ctx.strokeStyle = canPlace ? '#66ff66' : '#ff6666'; + this.ctx.lineWidth = 2; + this.ctx.setLineDash([5, 5]); + this.ctx.strokeRect(screen.x, screen.y, w, h); + this.ctx.setLineDash([]); + + // Rotation arrow preview + if (['belt', 'inserter', 'miner'].includes(currentTool)) { + this.ctx.save(); + this.ctx.translate(screen.x + w / 2, screen.y + h / 2); + this.ctx.rotate(rotation * Math.PI / 2); + this.ctx.fillStyle = canPlace ? '#66ff66' : '#ff6666'; + this.ctx.beginPath(); + this.ctx.moveTo(w * 0.3, 0); + this.ctx.lineTo(-w * 0.1, -w * 0.15); + this.ctx.lineTo(-w * 0.1, w * 0.15); + this.ctx.closePath(); + this.ctx.fill(); + this.ctx.restore(); + } + + this.ctx.globalAlpha = 1; + }, + + // Draw delete preview + drawDeletePreview(currentTool, mouseWorld) { + if (currentTool !== 'delete') return; + + const building = Buildings.getAt(mouseWorld.x, mouseWorld.y); + const tower = Towers.getAt(mouseWorld.x, mouseWorld.y); + + if (building) { + const tileScreenSize = CONFIG.TILE_SIZE * this.camera.zoom; + const screen = Utils.worldToScreen(building.x, building.y, this.camera); + const bSize = Utils.getBuildingSize(building.type); + const w = bSize.w * tileScreenSize; + const h = bSize.h * tileScreenSize; + + this.drawDeleteBox(screen.x, screen.y, w, h); + } else if (tower) { + const tileScreenSize = CONFIG.TILE_SIZE * this.camera.zoom; + const screen = Utils.worldToScreen(tower.x, tower.y, this.camera); + + this.drawDeleteBox(screen.x, screen.y, tileScreenSize, tileScreenSize); + } + }, + + // Draw delete box helper + drawDeleteBox(x, y, w, h) { + this.ctx.fillStyle = 'rgba(255, 50, 50, 0.4)'; + this.ctx.fillRect(x, y, w, h); + this.ctx.strokeStyle = '#ff3333'; + this.ctx.lineWidth = 3; + this.ctx.strokeRect(x, y, w, h); + + // X mark + this.ctx.strokeStyle = '#ff3333'; + this.ctx.lineWidth = 4 * this.camera.zoom; + this.ctx.beginPath(); + this.ctx.moveTo(x + w * 0.2, y + h * 0.2); + this.ctx.lineTo(x + w * 0.8, y + h * 0.8); + this.ctx.moveTo(x + w * 0.8, y + h * 0.2); + this.ctx.lineTo(x + w * 0.2, y + h * 0.8); + this.ctx.stroke(); + }, + + // Draw all towers + drawTowers() { + const tileScreenSize = CONFIG.TILE_SIZE * this.camera.zoom; + + Towers.list.forEach(tower => { + const type = Towers.TYPES[tower.type]; + const screen = Utils.worldToScreen(tower.x, tower.y, this.camera); + const size = tileScreenSize; + const pad = 3 * this.camera.zoom; + + // Shadow + this.ctx.fillStyle = 'rgba(0,0,0,0.3)'; + this.ctx.beginPath(); + this.ctx.arc(screen.x + size/2 + 3, screen.y + size/2 + 3, size/2 - pad, 0, Math.PI * 2); + this.ctx.fill(); + + // Base + const grad = this.ctx.createRadialGradient( + screen.x + size/2, screen.y + size/2, 0, + screen.x + size/2, screen.y + size/2, size/2 + ); + grad.addColorStop(0, type.color); + grad.addColorStop(1, type.colorDark); + this.ctx.fillStyle = grad; + this.ctx.beginPath(); + this.ctx.arc(screen.x + size/2, screen.y + size/2, size/2 - pad, 0, Math.PI * 2); + this.ctx.fill(); + + // Highlight + this.ctx.fillStyle = 'rgba(255,255,255,0.2)'; + this.ctx.beginPath(); + this.ctx.arc(screen.x + size/2, screen.y + size/3, size/4, 0, Math.PI * 2); + this.ctx.fill(); + + // Border + this.ctx.strokeStyle = 'rgba(0,0,0,0.5)'; + this.ctx.lineWidth = 2 * this.camera.zoom; + this.ctx.beginPath(); + this.ctx.arc(screen.x + size/2, screen.y + size/2, size/2 - pad, 0, Math.PI * 2); + this.ctx.stroke(); + + // Turret barrel + this.ctx.save(); + this.ctx.translate(screen.x + size/2, screen.y + size/2); + this.ctx.rotate(tower.angle); + + // Barrel + this.ctx.fillStyle = tower.firing ? '#fff' : '#333'; + this.ctx.fillRect(0, -4 * this.camera.zoom, size/2.5, 8 * this.camera.zoom); + + this.ctx.restore(); + + // Range indicator when selected + if (tower.target) { + this.ctx.strokeStyle = 'rgba(255,100,100,0.2)'; + this.ctx.lineWidth = 1; + this.ctx.beginPath(); + this.ctx.arc(screen.x + size/2, screen.y + size/2, type.range * this.camera.zoom, 0, Math.PI * 2); + this.ctx.stroke(); + } + + // Health bar + if (tower.health < tower.maxHealth) { + const barW = size - pad * 2; + const barH = 4 * this.camera.zoom; + const barX = screen.x + pad; + const barY = screen.y - barH - 4; + + this.ctx.fillStyle = 'rgba(0,0,0,0.5)'; + this.ctx.fillRect(barX, barY, barW, barH); + + const healthPct = tower.health / tower.maxHealth; + this.ctx.fillStyle = healthPct > 0.5 ? '#66ff66' : healthPct > 0.25 ? '#ffcc00' : '#ff3333'; + this.ctx.fillRect(barX, barY, barW * healthPct, barH); + } + }); + }, + + // Draw all enemies + drawEnemies() { + Enemies.list.forEach(enemy => { + const type = Enemies.TYPES[enemy.type]; + const screen = { + x: enemy.x * this.camera.zoom - this.camera.x, + y: enemy.y * this.camera.zoom - this.camera.y + }; + const size = CONFIG.TILE_SIZE * this.camera.zoom * type.size; + + // Shadow + this.ctx.fillStyle = 'rgba(0,0,0,0.3)'; + this.ctx.beginPath(); + this.ctx.ellipse(screen.x + 2, screen.y + 4, size/2, size/3, 0, 0, Math.PI * 2); + this.ctx.fill(); + + // Body + this.ctx.save(); + this.ctx.translate(screen.x, screen.y); + this.ctx.rotate(enemy.angle); + + // Damage flash + const color = enemy.damageFlash > 0 ? '#fff' : type.color; + if (enemy.damageFlash > 0) enemy.damageFlash -= 0.016; + + // Main body + const grad = this.ctx.createRadialGradient(0, 0, 0, 0, 0, size/2); + grad.addColorStop(0, color); + grad.addColorStop(1, type.colorDark); + this.ctx.fillStyle = grad; + + this.ctx.beginPath(); + this.ctx.ellipse(0, 0, size/2, size/3, 0, 0, Math.PI * 2); + this.ctx.fill(); + + // Eyes + this.ctx.fillStyle = '#ff0000'; + this.ctx.beginPath(); + this.ctx.arc(size/4, -size/8, size/10, 0, Math.PI * 2); + this.ctx.arc(size/4, size/8, size/10, 0, Math.PI * 2); + this.ctx.fill(); + + // Legs animation + this.ctx.strokeStyle = type.colorDark; + this.ctx.lineWidth = 2 * this.camera.zoom; + const legOffset = Math.sin(enemy.animTime) * 5; + + for (let i = 0; i < 3; i++) { + const angle = (i - 1) * 0.5; + const offset = i === 1 ? legOffset : -legOffset; + + // Left leg + this.ctx.beginPath(); + this.ctx.moveTo(-size/6, -size/4); + this.ctx.lineTo(-size/3 + offset, -size/2 - Math.abs(offset)); + this.ctx.stroke(); + + // Right leg + this.ctx.beginPath(); + this.ctx.moveTo(-size/6, size/4); + this.ctx.lineTo(-size/3 - offset, size/2 + Math.abs(offset)); + this.ctx.stroke(); + } + + this.ctx.restore(); + + // Health bar + if (enemy.health < enemy.maxHealth) { + const barW = size; + const barH = 4 * this.camera.zoom; + const barX = screen.x - size/2; + const barY = screen.y - size/2 - barH - 4; + + this.ctx.fillStyle = 'rgba(0,0,0,0.5)'; + this.ctx.fillRect(barX, barY, barW, barH); + + const healthPct = enemy.health / enemy.maxHealth; + this.ctx.fillStyle = healthPct > 0.5 ? '#66ff66' : healthPct > 0.25 ? '#ffcc00' : '#ff3333'; + this.ctx.fillRect(barX, barY, barW * healthPct, barH); + } + + // Burning effect + if (enemy.burning && enemy.burning.duration > 0) { + this.ctx.fillStyle = `rgba(255, 100, 0, ${0.3 + Math.sin(Date.now() / 100) * 0.2})`; + this.ctx.beginPath(); + this.ctx.arc(screen.x, screen.y, size/2, 0, Math.PI * 2); + this.ctx.fill(); + } + }); + }, + + // Draw projectiles + drawProjectiles() { + Towers.projectiles.forEach(proj => { + const screen = { + x: proj.x * this.camera.zoom - this.camera.x, + y: proj.y * this.camera.zoom - this.camera.y + }; + + // Projectile glow + this.ctx.fillStyle = proj.color; + this.ctx.shadowColor = proj.color; + this.ctx.shadowBlur = 10; + this.ctx.beginPath(); + this.ctx.arc(screen.x, screen.y, 4 * this.camera.zoom, 0, Math.PI * 2); + this.ctx.fill(); + this.ctx.shadowBlur = 0; + + // Trail + const dx = proj.targetX - proj.x; + const dy = proj.targetY - proj.y; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist > 0) { + const trailX = screen.x - (dx / dist) * 15 * this.camera.zoom; + const trailY = screen.y - (dy / dist) * 15 * this.camera.zoom; + + this.ctx.strokeStyle = proj.color; + this.ctx.lineWidth = 2 * this.camera.zoom; + this.ctx.globalAlpha = 0.5; + this.ctx.beginPath(); + this.ctx.moveTo(screen.x, screen.y); + this.ctx.lineTo(trailX, trailY); + this.ctx.stroke(); + this.ctx.globalAlpha = 1; + } + + // Chain lightning effect + if (proj.chainTargets && proj.chainTargets.length > 1) { + this.ctx.strokeStyle = proj.color; + this.ctx.lineWidth = 2 * this.camera.zoom; + this.ctx.globalAlpha = 0.8; + + for (let i = 0; i < proj.chainTargets.length - 1; i++) { + const t1 = proj.chainTargets[i]; + const t2 = proj.chainTargets[i + 1]; + const s1 = { + x: t1.x * this.camera.zoom - this.camera.x, + y: t1.y * this.camera.zoom - this.camera.y + }; + const s2 = { + x: t2.x * this.camera.zoom - this.camera.x, + y: t2.y * this.camera.zoom - this.camera.y + }; + + this.ctx.beginPath(); + this.ctx.moveTo(s1.x, s1.y); + // Jagged line for lightning effect + const midX = (s1.x + s2.x) / 2 + (Math.random() - 0.5) * 20; + const midY = (s1.y + s2.y) / 2 + (Math.random() - 0.5) * 20; + this.ctx.lineTo(midX, midY); + this.ctx.lineTo(s2.x, s2.y); + this.ctx.stroke(); + } + this.ctx.globalAlpha = 1; + } + }); + } +}; diff --git a/js/resources.js b/js/resources.js new file mode 100644 index 0000000..cb889ac --- /dev/null +++ b/js/resources.js @@ -0,0 +1,66 @@ +// Resource Management +const Resources = { + // Current resource counts + inventory: {}, + + // Initialize resources + init() { + this.inventory = Utils.deepClone(CONFIG.STARTING_RESOURCES); + }, + + // Get resource count + get(type) { + return this.inventory[type] || 0; + }, + + // Add resources + add(type, amount) { + this.inventory[type] = (this.inventory[type] || 0) + amount; + }, + + // Remove resources (returns false if not enough) + remove(type, amount) { + if (this.get(type) < amount) return false; + this.inventory[type] -= amount; + return true; + }, + + // Check if player can afford a cost + canAfford(costObj) { + if (CONFIG.DEV_MODE) return true; + if (!costObj) return true; + for (const [res, amount] of Object.entries(costObj)) { + if (this.get(res) < amount) return false; + } + return true; + }, + + // Pay a cost (deduct resources) + payCost(costObj) { + if (CONFIG.DEV_MODE) return true; + if (!costObj) return true; + if (!this.canAfford(costObj)) return false; + for (const [res, amount] of Object.entries(costObj)) { + this.remove(res, amount); + } + return true; + }, + + // Refund half cost (for demolishing) + refundHalf(costObj) { + if (!costObj) return; + for (const [res, amount] of Object.entries(costObj)) { + this.add(res, Math.floor(amount / 2)); + } + }, + + // Get all resources as object + getAll() { + return Utils.deepClone(this.inventory); + }, + + // Set all resources from object + setAll(data) { + this.inventory = Utils.deepClone(data); + } +}; diff --git a/js/saveload.js b/js/saveload.js new file mode 100644 index 0000000..db979b1 --- /dev/null +++ b/js/saveload.js @@ -0,0 +1,116 @@ +// Save/Load System +const SaveLoad = { + STORAGE_KEY: 'minifactory_saves', + + // Get all saves + getSaves() { + try { + return JSON.parse(localStorage.getItem(this.STORAGE_KEY) || '{}'); + } catch { + return {}; + } + }, + + // Set all saves + setSaves(saves) { + localStorage.setItem(this.STORAGE_KEY, JSON.stringify(saves)); + }, + + // Save game to slot + saveGame(slotId) { + const saves = this.getSaves(); + saves[slotId] = { + date: new Date().toISOString(), + resources: Resources.getAll(), + terrain: Terrain.getData(), + buildings: Buildings.getData(), + towers: Towers.getData(), + enemies: Enemies.getData(), + camera: { ...Renderer.camera }, + simulation: Simulation.getData() + }; + this.setSaves(saves); + }, + + // Load game from slot + loadGame(slotId) { + const saves = this.getSaves(); + const save = saves[slotId]; + if (!save) return false; + + Resources.setAll(save.resources); + Terrain.setData(save.terrain); + Buildings.setData(save.buildings); + if (save.towers) Towers.setData(save.towers); + if (save.enemies) Enemies.setData(save.enemies); + Renderer.camera = { ...save.camera }; + if (save.simulation) { + Simulation.setData(save.simulation); + } + + return true; + }, + + // Delete a save + deleteSave(slotId) { + const saves = this.getSaves(); + delete saves[slotId]; + this.setSaves(saves); + }, + + // Save to new slot + saveToNewSlot() { + const slotId = 'save_' + Date.now(); + this.saveGame(slotId); + }, + + // Update save slots UI + updateSaveSlots() { + const container = document.getElementById('save-slots'); + const saves = this.getSaves(); + const slots = Object.entries(saves); + + if (slots.length === 0) { + container.innerHTML = '