Spaces:
Paused
Paused
| // 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(); | |
| } | |
| } | |