// Game Renderer - handles all canvas drawing operations import { GameState, Block, Enemy, PlayerState, Position } from './types'; import { GAME_CONFIG } from '../constants'; import { LevelTheme } from './LevelManager'; const { CANVAS_WIDTH, CANVAS_HEIGHT, TILE_SIZE, COLORS } = GAME_CONFIG; const THEME_COLORS = { overworld: { sky: COLORS.SKY, ground: COLORS.GROUND }, underground: { sky: '#1a1a2e', ground: '#4a4a4a' }, castle: { sky: '#2d1b1b', ground: '#5a3030' }, }; export class Renderer { private ctx: CanvasRenderingContext2D; private animationFrame: number = 0; constructor(ctx: CanvasRenderingContext2D) { this.ctx = ctx; } private currentTheme: LevelTheme = 'overworld'; clear(): void { // Draw sky background based on theme this.ctx.fillStyle = THEME_COLORS[this.currentTheme].sky; this.ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); } drawClouds(cameraX: number): void { this.ctx.fillStyle = '#ffffff'; const clouds = [ { x: 100, y: 60, width: 80, height: 30 }, { x: 400, y: 80, width: 100, height: 35 }, { x: 700, y: 50, width: 70, height: 25 }, { x: 1100, y: 70, width: 90, height: 30 }, { x: 1500, y: 55, width: 75, height: 28 }, { x: 1900, y: 85, width: 85, height: 32 }, { x: 2300, y: 60, width: 95, height: 35 }, { x: 2700, y: 75, width: 80, height: 30 }, ]; clouds.forEach(cloud => { const screenX = cloud.x - cameraX * 0.3; // Parallax effect if (screenX > -cloud.width && screenX < CANVAS_WIDTH + cloud.width) { // Draw cloud as overlapping circles this.ctx.beginPath(); this.ctx.arc(screenX, cloud.y, cloud.height * 0.6, 0, Math.PI * 2); this.ctx.arc(screenX + cloud.width * 0.3, cloud.y - 5, cloud.height * 0.7, 0, Math.PI * 2); this.ctx.arc(screenX + cloud.width * 0.6, cloud.y, cloud.height * 0.5, 0, Math.PI * 2); this.ctx.fill(); } }); } drawBlock(block: Block, cameraX: number): void { const screenX = block.x - cameraX; // Skip if off screen if (screenX < -block.width || screenX > CANVAS_WIDTH) return; switch (block.type) { case 'ground': this.drawGroundBlock(screenX, block.y, block.width, block.height); break; case 'brick': this.drawBrickBlock(screenX, block.y, block.width, block.height); break; case 'question': this.drawQuestionBlock(screenX, block.y, block.width, block.height, block.isHit); break; case 'pipe': this.drawPipe(screenX, block.y, block.width, block.height); break; case 'coin': this.drawCoin(screenX, block.y, block.width, block.height); break; } } private drawGroundBlock(x: number, y: number, width: number, height: number): void { // Main color this.ctx.fillStyle = COLORS.GROUND; this.ctx.fillRect(x, y, width, height); // Texture pattern this.ctx.fillStyle = '#8B4513'; this.ctx.fillRect(x + 2, y + 2, 4, 4); this.ctx.fillRect(x + width - 8, y + height - 8, 4, 4); this.ctx.fillRect(x + width / 2, y + height / 2 - 2, 3, 3); // Border this.ctx.strokeStyle = '#5a3000'; this.ctx.lineWidth = 1; this.ctx.strokeRect(x, y, width, height); } private drawBrickBlock(x: number, y: number, width: number, height: number): void { this.ctx.fillStyle = COLORS.BRICK; this.ctx.fillRect(x, y, width, height); // Brick pattern this.ctx.strokeStyle = '#5a3000'; this.ctx.lineWidth = 2; this.ctx.strokeRect(x, y, width, height); // Horizontal line this.ctx.beginPath(); this.ctx.moveTo(x, y + height / 2); this.ctx.lineTo(x + width, y + height / 2); this.ctx.stroke(); // Vertical lines this.ctx.beginPath(); this.ctx.moveTo(x + width / 2, y); this.ctx.lineTo(x + width / 2, y + height / 2); this.ctx.stroke(); this.ctx.beginPath(); this.ctx.moveTo(x + width / 4, y + height / 2); this.ctx.lineTo(x + width / 4, y + height); this.ctx.stroke(); this.ctx.beginPath(); this.ctx.moveTo(x + width * 3 / 4, y + height / 2); this.ctx.lineTo(x + width * 3 / 4, y + height); this.ctx.stroke(); } private drawQuestionBlock(x: number, y: number, width: number, height: number, isHit?: boolean): void { // Animate if not hit const bounce = isHit ? 0 : Math.sin(this.animationFrame * 0.1) * 2; this.ctx.fillStyle = isHit ? '#8B7355' : COLORS.QUESTION; this.ctx.fillRect(x, y + bounce, width, height); // Border this.ctx.strokeStyle = '#8B4513'; this.ctx.lineWidth = 2; this.ctx.strokeRect(x, y + bounce, width, height); // Question mark or empty this.ctx.fillStyle = isHit ? '#666' : '#fff'; this.ctx.font = 'bold 20px Arial'; this.ctx.textAlign = 'center'; this.ctx.textBaseline = 'middle'; this.ctx.fillText(isHit ? '' : '?', x + width / 2, y + height / 2 + bounce); } private drawPipe(x: number, y: number, width: number, height: number): void { // Pipe body this.ctx.fillStyle = COLORS.PIPE; this.ctx.fillRect(x, y, width, height); // Pipe rim (top part is wider) if (y <= GAME_CONFIG.LEVEL_HEIGHT - TILE_SIZE * 2 - height + TILE_SIZE) { this.ctx.fillStyle = '#00c800'; this.ctx.fillRect(x - 4, y, width + 8, TILE_SIZE / 2); } // Highlights this.ctx.fillStyle = '#00e000'; this.ctx.fillRect(x + 4, y, 8, height); // Shadow this.ctx.fillStyle = '#006800'; this.ctx.fillRect(x + width - 8, y, 6, height); // Border this.ctx.strokeStyle = '#004000'; this.ctx.lineWidth = 2; this.ctx.strokeRect(x, y, width, height); } private drawCoin(x: number, y: number, width: number, height: number): void { // Animate coin const scale = 0.7 + Math.abs(Math.sin(this.animationFrame * 0.15)) * 0.3; const centerX = x + width / 2; const centerY = y + height / 2; this.ctx.save(); this.ctx.translate(centerX, centerY); this.ctx.scale(scale, 1); // Coin circle this.ctx.beginPath(); this.ctx.arc(0, 0, width / 2 - 2, 0, Math.PI * 2); this.ctx.fillStyle = COLORS.COIN; this.ctx.fill(); this.ctx.strokeStyle = '#cc9900'; this.ctx.lineWidth = 2; this.ctx.stroke(); // Inner detail this.ctx.beginPath(); this.ctx.arc(0, 0, width / 4, 0, Math.PI * 2); this.ctx.strokeStyle = '#ffeb3b'; this.ctx.stroke(); this.ctx.restore(); } drawPlayer(player: PlayerState, cameraX: number): void { const screenX = player.x - cameraX; if (!player.isAlive) { // Death animation - player falling this.ctx.save(); this.ctx.globalAlpha = 0.7; } // Body (blue overalls) this.ctx.fillStyle = COLORS.MARIO_BLUE; this.ctx.fillRect(screenX + 4, player.y + 20, 24, 28); // Head this.ctx.fillStyle = COLORS.MARIO_SKIN; this.ctx.fillRect(screenX + 6, player.y + 4, 20, 16); // Hat this.ctx.fillStyle = COLORS.MARIO_RED; this.ctx.fillRect(screenX + 4, player.y, 24, 8); if (player.facingRight) { this.ctx.fillRect(screenX + 24, player.y + 4, 6, 6); } else { this.ctx.fillRect(screenX + 2, player.y + 4, 6, 6); } // Eyes this.ctx.fillStyle = '#000'; if (player.facingRight) { this.ctx.fillRect(screenX + 18, player.y + 8, 4, 4); } else { this.ctx.fillRect(screenX + 10, player.y + 8, 4, 4); } // Mustache this.ctx.fillStyle = '#4a2800'; this.ctx.fillRect(screenX + 8, player.y + 14, 16, 4); // Buttons on overalls this.ctx.fillStyle = COLORS.COIN; this.ctx.fillRect(screenX + 10, player.y + 24, 4, 4); this.ctx.fillRect(screenX + 18, player.y + 24, 4, 4); if (!player.isAlive) { this.ctx.restore(); } } drawEnemy(enemy: Enemy, cameraX: number): void { const screenX = enemy.x - cameraX; // Skip if off screen if (screenX < -enemy.width || screenX > CANVAS_WIDTH) return; if (!enemy.isAlive) return; if (enemy.type === 'koopa') { this.drawKoopa(screenX, enemy); return; } // Goomba body this.ctx.fillStyle = COLORS.GOOMBA; // Head (mushroom shape) this.ctx.beginPath(); this.ctx.arc(screenX + enemy.width / 2, enemy.y + 10, 14, Math.PI, 0); this.ctx.fill(); // Body this.ctx.fillRect(screenX + 4, enemy.y + 10, enemy.width - 8, 14); // Feet (animated) const footOffset = Math.sin(this.animationFrame * 0.3) * 2; this.ctx.fillStyle = '#000'; this.ctx.fillRect(screenX + 2, enemy.y + 24, 10, 8 + footOffset); this.ctx.fillRect(screenX + enemy.width - 12, enemy.y + 24, 10, 8 - footOffset); // Eyes this.ctx.fillStyle = '#fff'; this.ctx.fillRect(screenX + 8, enemy.y + 8, 6, 8); this.ctx.fillRect(screenX + enemy.width - 14, enemy.y + 8, 6, 8); // Pupils this.ctx.fillStyle = '#000'; const pupilOffset = enemy.direction > 0 ? 2 : 0; this.ctx.fillRect(screenX + 10 + pupilOffset, enemy.y + 12, 3, 4); this.ctx.fillRect(screenX + enemy.width - 13 + pupilOffset, enemy.y + 12, 3, 4); // Eyebrows (angry) this.ctx.strokeStyle = '#000'; this.ctx.lineWidth = 2; this.ctx.beginPath(); this.ctx.moveTo(screenX + 6, enemy.y + 6); this.ctx.lineTo(screenX + 14, enemy.y + 8); this.ctx.stroke(); this.ctx.beginPath(); this.ctx.moveTo(screenX + enemy.width - 6, enemy.y + 6); this.ctx.lineTo(screenX + enemy.width - 14, enemy.y + 8); this.ctx.stroke(); } private drawKoopa(screenX: number, enemy: Enemy): void { // Shell (green) this.ctx.fillStyle = '#00aa00'; this.ctx.beginPath(); this.ctx.ellipse(screenX + enemy.width / 2, enemy.y + enemy.height - 12, 14, 10, 0, 0, Math.PI * 2); this.ctx.fill(); // Shell pattern this.ctx.strokeStyle = '#006600'; this.ctx.lineWidth = 2; this.ctx.beginPath(); this.ctx.arc(screenX + enemy.width / 2, enemy.y + enemy.height - 12, 8, 0, Math.PI * 2); this.ctx.stroke(); // Head this.ctx.fillStyle = '#ffcc00'; this.ctx.beginPath(); this.ctx.arc(screenX + enemy.width / 2, enemy.y + 10, 10, 0, Math.PI * 2); this.ctx.fill(); // Eyes this.ctx.fillStyle = '#fff'; this.ctx.fillRect(screenX + enemy.width / 2 - 6, enemy.y + 6, 5, 6); this.ctx.fillRect(screenX + enemy.width / 2 + 1, enemy.y + 6, 5, 6); // Pupils this.ctx.fillStyle = '#000'; const pupilOffset = enemy.direction > 0 ? 2 : 0; this.ctx.fillRect(screenX + enemy.width / 2 - 4 + pupilOffset, enemy.y + 8, 2, 3); this.ctx.fillRect(screenX + enemy.width / 2 + 2 + pupilOffset, enemy.y + 8, 2, 3); // Feet const footOffset = Math.sin(this.animationFrame * 0.3) * 2; this.ctx.fillStyle = '#ffcc00'; this.ctx.fillRect(screenX + 4, enemy.y + enemy.height - 6, 8, 6 + footOffset); this.ctx.fillRect(screenX + enemy.width - 12, enemy.y + enemy.height - 6, 8, 6 - footOffset); } drawFlag(position: Position, cameraX: number): void { const screenX = position.x - cameraX; // Skip if off screen if (screenX < -100 || screenX > CANVAS_WIDTH + 100) return; // Flag pole this.ctx.fillStyle = '#00aa00'; this.ctx.fillRect(screenX, position.y, 8, GAME_CONFIG.LEVEL_HEIGHT - position.y - TILE_SIZE * 2); // Pole ball on top this.ctx.beginPath(); this.ctx.arc(screenX + 4, position.y - 5, 8, 0, Math.PI * 2); this.ctx.fillStyle = COLORS.COIN; this.ctx.fill(); // Flag const flagWave = Math.sin(this.animationFrame * 0.1) * 3; this.ctx.fillStyle = COLORS.FLAG; this.ctx.beginPath(); this.ctx.moveTo(screenX + 8, position.y); this.ctx.lineTo(screenX + 50 + flagWave, position.y + 20); this.ctx.lineTo(screenX + 8, position.y + 40); this.ctx.closePath(); this.ctx.fill(); // Star on flag this.ctx.fillStyle = '#fff'; this.ctx.font = '16px Arial'; this.ctx.fillText('★', screenX + 22 + flagWave / 2, position.y + 25); } drawUI(player: PlayerState, levelNumber: number = 1): void { // UI Background this.ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'; this.ctx.fillRect(10, 10, 280, 60); // Score this.ctx.fillStyle = '#fff'; this.ctx.font = 'bold 16px "Press Start 2P", monospace'; this.ctx.textAlign = 'left'; this.ctx.fillText(`SCORE`, 20, 32); this.ctx.fillText(`${player.score.toString().padStart(6, '0')}`, 20, 52); // Coins this.ctx.fillStyle = COLORS.COIN; this.ctx.beginPath(); this.ctx.arc(150, 35, 10, 0, Math.PI * 2); this.ctx.fill(); this.ctx.fillStyle = '#fff'; this.ctx.fillText(`×${player.coins}`, 165, 40); // Level indicator this.ctx.fillStyle = '#fff'; this.ctx.fillText(`WORLD`, 220, 32); this.ctx.fillText(`1-${levelNumber}`, 220, 52); } update(): void { this.animationFrame++; } render(state: GameState, theme: LevelTheme = 'overworld', levelNumber: number = 1): void { this.currentTheme = theme; this.clear(); // Only draw clouds in overworld if (theme === 'overworld') { this.drawClouds(state.camera.x); } // Draw all blocks state.blocks.forEach(block => this.drawBlock(block, state.camera.x)); // Draw flag this.drawFlag(state.level.flagPosition, state.camera.x); // Draw enemies state.enemies.forEach(enemy => this.drawEnemy(enemy, state.camera.x)); // Draw player this.drawPlayer(state.player, state.camera.x); // Draw UI this.drawUI(state.player, levelNumber); this.update(); } }