/**
* 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);
}