super-mario / lib /engine /Renderer.ts
asemxin
Add multiple levels and sound effects system
4539eae
// 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();
}
}