diff options
Diffstat (limited to 'html/tower/js/renderer.js')
-rw-r--r-- | html/tower/js/renderer.js | 381 |
1 files changed, 381 insertions, 0 deletions
diff --git a/html/tower/js/renderer.js b/html/tower/js/renderer.js new file mode 100644 index 0000000..fce4b88 --- /dev/null +++ b/html/tower/js/renderer.js @@ -0,0 +1,381 @@ +/** + * 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<Array<string>>} 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<Object>} 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); +} \ No newline at end of file |