/** * Rendering Module * * This module handles all game rendering operations using HTML5 Canvas. * Demonstrates key game development patterns: * 1. Layer-based rendering * 2. Particle systems * 3. Visual state feedback * 4. Canvas state management * * @module renderer */ /** * Renders the game grid with path and hover previews * Implements visual feedback for player actions * * @param {CanvasRenderingContext2D} ctx - Canvas rendering context * @param {Array>} grid - Game grid state */ function renderGrid(ctx, grid) { const cellSize = canvas.width / 20; // Draw grid lines for visual reference ctx.strokeStyle = '#ccc'; ctx.lineWidth = 1; for (let i = 0; i <= 20; i++) { // Vertical lines ctx.beginPath(); ctx.moveTo(i * cellSize, 0); ctx.lineTo(i * cellSize, canvas.height); ctx.stroke(); // Horizontal lines ctx.beginPath(); ctx.moveTo(0, i * cellSize); ctx.lineTo(canvas.width, i * cellSize); ctx.stroke(); } // Render grid cells with path highlighting grid.forEach((row, y) => { row.forEach((cell, x) => { if (cell === 'path') { ctx.fillStyle = '#f4a460'; ctx.fillRect(x * cellSize, y * cellSize, cellSize, cellSize); } }); }); // Render tower placement preview if (gameState.phase === GamePhase.PLACEMENT && draggedTowerType && hoverCell) { const tower = TowerTypes[draggedTowerType]; const canPlace = grid[hoverCell.y][hoverCell.x] === 'empty' && gameState.playerCurrency >= tower.cost; // Visual feedback for placement validity ctx.fillStyle = canPlace ? tower.color + '80' : 'rgba(255, 0, 0, 0.3)'; ctx.fillRect( hoverCell.x * cellSize, hoverCell.y * cellSize, cellSize, cellSize ); // Range indicator preview ctx.beginPath(); ctx.arc( (hoverCell.x + 0.5) * cellSize, (hoverCell.y + 0.5) * cellSize, tower.range * cellSize, 0, Math.PI * 2 ); ctx.strokeStyle = canPlace ? tower.color + '40' : 'rgba(255, 0, 0, 0.2)'; ctx.stroke(); } } /** * Renders all enemies with health indicators and effects * Implements visual state representation * * @param {CanvasRenderingContext2D} ctx - Canvas rendering context * @param {Array} enemies - Array of enemy objects */ function renderEnemies(ctx, enemies) { const cellSize = canvas.width / 20; enemies.forEach(enemy => { // Health-based opacity for visual feedback const healthPercent = enemy.currentHealth / enemy.maxHealth; const opacity = 0.3 + (healthPercent * 0.7); // Dynamic color based on enemy state const color = EnemyTypes[enemy.type].color; const hexOpacity = Math.floor(opacity * 255).toString(16).padStart(2, '0'); // Draw enemy body with solid black border ctx.beginPath(); ctx.arc( (enemy.position.x + 0.5) * cellSize, (enemy.position.y + 0.5) * cellSize, cellSize / 3, 0, Math.PI * 2 ); // Fill with dynamic opacity ctx.fillStyle = `${color}${hexOpacity}`; ctx.fill(); // Add solid black border ctx.strokeStyle = 'black'; ctx.lineWidth = 2; ctx.stroke(); // Range indicator for special enemy types if (EnemyTypes[enemy.type].isRanged) { ctx.beginPath(); ctx.arc( (enemy.position.x + 0.5) * cellSize, (enemy.position.y + 0.5) * cellSize, EnemyTypes[enemy.type].attackRange * cellSize, 0, Math.PI * 2 ); ctx.strokeStyle = `${EnemyTypes[enemy.type].color}40`; ctx.stroke(); } }); } /** * Renders game UI elements with clean state management * Implements heads-up display (HUD) pattern * * @param {CanvasRenderingContext2D} ctx - Canvas rendering context * @param {Object} gameState - Current game state */ function renderUI(ctx, gameState) { const padding = 20; const lineHeight = 30; const startY = padding; const width = 200; const height = lineHeight * 5; // Save the current canvas state ctx.save(); // Reset any transformations ctx.setTransform(1, 0, 0, 1, 0, 0); // Semi-transparent background for readability ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'; ctx.fillRect(0, 0, width, height + padding); // Text rendering setup ctx.fillStyle = 'black'; ctx.font = '20px Arial'; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; // Game state information ctx.fillText(`Level: ${gameState.level}`, padding, startY); ctx.fillText(`Currency: $${gameState.currency}`, padding, startY + lineHeight); ctx.fillText(`Phase: ${gameState.phase}`, padding, startY + lineHeight * 2); ctx.fillText(`Destroyed: ${gameState.enemiesDestroyed}`, padding, startY + lineHeight * 3); ctx.fillText(`Escaped: ${gameState.enemiesEscaped}`, padding, startY + lineHeight * 4); // Restore the canvas state ctx.restore(); } function renderTowers(ctx, towers) { const cellSize = canvas.width / 20; towers.forEach(tower => { const healthPercent = tower.currentHealth / tower.maxHealth; // Draw tower body ctx.fillStyle = tower.color + Math.floor(healthPercent * 255).toString(16).padStart(2, '0'); ctx.fillRect( tower.position.x * cellSize + cellSize * 0.1, tower.position.y * cellSize + cellSize * 0.1, cellSize * 0.8, cellSize * 0.8 ); // Draw ammo count ctx.fillStyle = 'white'; ctx.font = '12px Arial'; ctx.textAlign = 'center'; ctx.fillText( tower.ammo, (tower.position.x + 0.5) * cellSize, (tower.position.y + 0.7) * cellSize ); // Draw range indicator if (gameState.phase === GamePhase.PLACEMENT) { ctx.beginPath(); ctx.arc( (tower.position.x + 0.5) * cellSize, (tower.position.y + 0.5) * cellSize, tower.range * cellSize, 0, Math.PI * 2 ); ctx.strokeStyle = tower.color + '40'; ctx.stroke(); } }); } // Add new render function for particles function renderParticles(ctx, particles) { particles.forEach(particle => { const age = performance.now() - particle.createdAt; const lifePercent = age / particle.lifetime; if (lifePercent <= 1) { if (particle.type === 'SLIME_TRAIL') { // Calculate opacity based on lifetime and fade start let opacity = 1; if (lifePercent > particle.fadeStart) { opacity = 1 - ((lifePercent - particle.fadeStart) / (1 - particle.fadeStart)); } opacity *= 0.3; // Make it translucent ctx.globalAlpha = opacity; ctx.fillStyle = particle.color; // Draw a circular slime splat ctx.beginPath(); ctx.arc( particle.position.x, particle.position.y, particle.size * (1 - lifePercent * 0.3), // Slightly shrink over time 0, Math.PI * 2 ); ctx.fill(); // Add some variation to the splat for (let i = 0; i < 3; i++) { const angle = (Math.PI * 2 * i) / 3; const distance = particle.size * 0.4; ctx.beginPath(); ctx.arc( particle.position.x + Math.cos(angle) * distance, particle.position.y + Math.sin(angle) * distance, particle.size * 0.4 * (1 - lifePercent * 0.3), 0, Math.PI * 2 ); ctx.fill(); } } else if (particle.type === 'AOE_EXPLOSION') { // Draw expanding circle const radius = particle.initialRadius + (particle.finalRadius - particle.initialRadius) * lifePercent; // Draw multiple rings for better effect const numRings = 3; for (let i = 0; i < numRings; i++) { const ringRadius = radius * (1 - (i * 0.2)); const ringAlpha = (1 - lifePercent) * (1 - (i * 0.3)); ctx.beginPath(); ctx.arc( particle.position.x, particle.position.y, ringRadius, 0, Math.PI * 2 ); ctx.strokeStyle = particle.color; ctx.lineWidth = particle.ringWidth * (1 - (i * 0.2)); ctx.globalAlpha = ringAlpha; ctx.stroke(); } // Draw affected area ctx.beginPath(); ctx.arc( particle.position.x, particle.position.y, radius, 0, Math.PI * 2 ); ctx.fillStyle = particle.color + '20'; // Very transparent fill ctx.fill(); } else { // Original particle rendering ctx.fillStyle = particle.color; ctx.beginPath(); ctx.arc( particle.position.x, particle.position.y, particle.size * (1 - lifePercent), 0, Math.PI * 2 ); ctx.fill(); } } }); ctx.globalAlpha = 1; } // Add new render function for projectiles function renderProjectiles(ctx, projectiles) { const cellSize = canvas.width / 20; projectiles.forEach(projectile => { const age = performance.now() - projectile.createdAt; const progress = age / projectile.lifetime; if (progress <= 1) { // Draw projectile trail ctx.beginPath(); ctx.moveTo( projectile.startPos.x * cellSize + cellSize / 2, projectile.startPos.y * cellSize + cellSize / 2 ); const currentX = projectile.startPos.x + (projectile.targetPos.x - projectile.startPos.x) * progress; const currentY = projectile.startPos.y + (projectile.targetPos.y - projectile.startPos.y) * progress; ctx.lineTo( currentX * cellSize + cellSize / 2, currentY * cellSize + cellSize / 2 ); ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; ctx.stroke(); // Draw projectile head ctx.beginPath(); ctx.arc( currentX * cellSize + cellSize / 2, currentY * cellSize + cellSize / 2, 4, 0, Math.PI * 2 ); ctx.fillStyle = '#fff'; ctx.fill(); } }); } // Update level complete message in game.js function handleLevelComplete() { gameState.phase = GamePhase.TRANSITION; // Calculate ammo bonus let ammoBonus = 0; gameState.towers.forEach(tower => { ammoBonus += tower.ammo * 2; }); const message = ` Level ${gameState.level} Complete! Current Money: $${gameState.currency} Ammo Bonus: +$${ammoBonus} Level Bonus: +$10 Ready for Level ${gameState.level + 1}? `; setTimeout(() => { if (confirm(message)) { startNextLevel(); } }, 100); }