import React, { useRef, useEffect } from 'react'; import { ArrowLeft, ArrowRight, ArrowUp, Megaphone } from 'lucide-react'; import { GameStatus, Player, Obstacle, Platform, Collectible, Particle, FloatingText, ObstacleType, BackgroundObject, ParallaxObject, CollectibleType, MiaCustomization, PlatformType } from '../types'; import { GRAVITY, JUMP_FORCE, DOUBLE_JUMP_FORCE, GROUND_HEIGHT, MIA_WIDTH, MIA_HEIGHT, WEE_WIDTH_MIN, WEE_WIDTH_MAX, WEE_HEIGHT, POO_WIDTH_MIN, POO_WIDTH_MAX, CAT_WIDTH, CAT_HEIGHT, BIRD_WIDTH, BIRD_HEIGHT, BIRD_SPEED, LEAF_WIDTH, LEAF_HEIGHT, LEAF_FALL_SPEED, ROOMBA_WIDTH, ROOMBA_HEIGHT, ROOMBA_SPEED, FLY_WIDTH, FLY_HEIGHT, FLY_SPEED, MAX_SPEED_BASE, ZOOMIES_SPEED_MULTIPLIER, ACCELERATION, FRICTION, PLATFORM_WIDTH_MIN, PLATFORM_WIDTH_MAX, OTTOMAN_HEIGHT, PLATFORM_HEIGHT, COUNTER_HEIGHT, SOFA_HEIGHT, FRIDGE_HEIGHT, BED_HEIGHT, BATHTUB_HEIGHT, TOILET_HEIGHT, CHICKEN_WIDTH, BONE_WIDTH, CHICKEN_HEIGHT, BONE_HEIGHT, STEAK_WIDTH, STEAK_HEIGHT, BISCUIT_WIDTH, BISCUIT_HEIGHT, COLOR_WHITE, COLOR_BONE, THEME_LIVING_ROOM_WALL, THEME_LIVING_ROOM_FLOOR, THEME_KITCHEN_WALL, THEME_KITCHEN_FLOOR, THEME_GARDEN_WALL, THEME_GARDEN_FLOOR, THEME_BEDROOM_WALL, THEME_BEDROOM_FLOOR, THEME_BATHROOM_WALL, THEME_BATHROOM_FLOOR, CHUNK_SIZE, BARK_COOLDOWN, BARK_DURATION, BARK_RANGE, ZOOMIES_DURATION, MAX_LIVES, INVINCIBILITY_FRAMES, LEVEL_SCORE_THRESHOLD, SPEED_INCREASE_PER_LEVEL, SHAKE_INTENSITY, SHAKE_DURATION, CAT_POUNCE_RANGE, CAT_POUNCE_VY, CAT_POUNCE_VX } from '../constants'; // --- Sound Synthesizer --- class SoundSynthesizer { ctx: AudioContext | null = null; bgmGain: GainNode | null = null; isPlayingMusic: boolean = false; currentNoteIndex: number = 0; musicInterval: number | null = null; constructor() { try { const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext; this.ctx = new AudioContextClass(); } catch (e) { console.error("Web Audio API not supported"); } } resume() { if (this.ctx && this.ctx.state === 'suspended') { this.ctx.resume(); } } playTone(freq: number, type: OscillatorType, duration: number, vol: number = 0.1) { if (!this.ctx) return; const osc = this.ctx.createOscillator(); const gain = this.ctx.createGain(); osc.type = type; osc.frequency.setValueAtTime(freq, this.ctx.currentTime); gain.gain.setValueAtTime(vol, this.ctx.currentTime); gain.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + duration); osc.connect(gain); gain.connect(this.ctx.destination); osc.start(); osc.stop(this.ctx.currentTime + duration); } playJump() { if (!this.ctx) return; this.playTone(300, 'triangle', 0.1, 0.1); } playBark() { if (!this.ctx) return; this.playTone(150, 'sawtooth', 0.15, 0.1); setTimeout(() => this.playTone(120, 'sawtooth', 0.15, 0.1), 50); } playCollect(isRare: boolean) { if (!this.ctx) return; if (isRare) { this.playTone(880, 'sine', 0.1, 0.1); setTimeout(() => this.playTone(1100, 'sine', 0.2, 0.1), 100); } else { this.playTone(660, 'sine', 0.1, 0.05); setTimeout(() => this.playTone(880, 'sine', 0.1, 0.05), 50); } } playDamage() { if (!this.ctx) return; this.playTone(100, 'square', 0.2, 0.1); this.playTone(80, 'sawtooth', 0.3, 0.1); } playLevelComplete() { if (!this.ctx) return; const now = this.ctx.currentTime; [523.25, 659.25, 783.99, 1046.50].forEach((freq, i) => { const osc = this.ctx!.createOscillator(); const gain = this.ctx!.createGain(); osc.frequency.setValueAtTime(freq, now + i*0.1); gain.gain.setValueAtTime(0.1, now + i*0.1); gain.gain.exponentialRampToValueAtTime(0.01, now + i*0.1 + 0.5); osc.connect(gain); gain.connect(this.ctx!.destination); osc.start(now + i*0.1); osc.stop(now + i*0.1 + 0.5); }); } startMusic(zoneIndex: number) { if (!this.ctx || this.isPlayingMusic) return; this.isPlayingMusic = true; this.bgmGain = this.ctx.createGain(); this.bgmGain.gain.value = 0.08; this.bgmGain.connect(this.ctx.destination); let notes: number[] = []; let speed = 300; let wave: OscillatorType = 'triangle'; // 0=Living, 1=Kitchen, 2=Garden, 3=Bedroom, 4=Bathroom if (zoneIndex === 0) { // Living Room - Jaunty notes = [261, 0, 329, 0, 392, 0, 523, 392, 329, 0, 293, 0, 220, 0]; speed = 250; } else if (zoneIndex === 1) { // Kitchen - Busy notes = [440, 440, 493, 440, 587, 0, 554, 0, 440, 0, 392, 440]; speed = 200; wave = 'square'; if(this.bgmGain) this.bgmGain.gain.value = 0.04; } else if (zoneIndex === 2) { // Garden - Relaxed notes = [196, 0, 0, 246, 0, 293, 0, 0, 392, 0, 293, 0, 246, 0]; speed = 400; wave = 'sine'; } else if (zoneIndex === 3) { // Bedroom - Lullaby-ish notes = [329, 0, 392, 0, 329, 0, 261, 0, 196, 0, 0, 0]; speed = 500; wave = 'sine'; } else { // Bathroom - Echoey/Plucky notes = [523, 0, 659, 0, 0, 523, 0, 659, 0]; speed = 300; wave = 'triangle'; } this.musicInterval = window.setInterval(() => { if (!this.ctx || !this.bgmGain) return; const freq = notes[this.currentNoteIndex]; if (freq > 0) { const osc = this.ctx.createOscillator(); osc.type = wave; osc.frequency.setValueAtTime(freq, this.ctx.currentTime); osc.connect(this.bgmGain); osc.start(); osc.stop(this.ctx.currentTime + 0.15); } this.currentNoteIndex = (this.currentNoteIndex + 1) % notes.length; }, speed); } stopMusic() { if (this.musicInterval) clearInterval(this.musicInterval); this.isPlayingMusic = false; if (this.bgmGain) { this.bgmGain.disconnect(); this.bgmGain = null; } } updateMusicTheme(zoneIndex: number) { this.stopMusic(); this.startMusic(zoneIndex); } } interface GameCanvasProps { gameStatus: GameStatus; setGameStatus: (status: GameStatus) => void; setScore: (score: number) => void; setLives: (lives: number) => void; canvasRef: React.RefObject; customization: MiaCustomization; } const GameCanvas: React.FC = ({ gameStatus, setGameStatus, setScore, setLives, canvasRef, customization }) => { const soundSystem = useRef(new SoundSynthesizer()); const gameStateRef = useRef({ player: { x: 100, y: 0, vx: 0, vy: 0, width: MIA_WIDTH, height: MIA_HEIGHT, isGrounded: true, jumpCount: 0, runFrame: 0, facingRight: true, isBarking: false, barkTimer: 0, barkCooldown: 0, isZoomies: false, zoomiesTimer: 0, lives: MAX_LIVES, maxLives: MAX_LIVES, invincibilityTimer: 0, } as Player, camera: { x: 0 }, keys: { left: false, right: false, up: false, bark: false }, obstacles: [] as Obstacle[], platforms: [] as Platform[], collectibles: [] as Collectible[], particles: [] as Particle[], floatingTexts: [] as FloatingText[], backgroundObjects: [] as BackgroundObject[], parallaxObjects: [] as ParallaxObject[], score: 0, level: 1, generatedUntilX: 0, shakeTimer: 0, lastTime: 0, animationId: 0, levelCompleteProcessed: false }); // --- Physics Helpers --- const resetGame = (canvas: HTMLCanvasElement, keepLevel: boolean = false) => { soundSystem.current.stopMusic(); const state = gameStateRef.current; // If we are just starting a new level, keep score and lives, but reset positions const newLevel = keepLevel ? state.level : 1; const newScore = keepLevel ? state.score : 0; const newLives = keepLevel ? state.player.lives : MAX_LIVES; const zoneIndex = (newLevel - 1) % 5; gameStateRef.current = { player: { x: 100, y: canvas.height - GROUND_HEIGHT - MIA_HEIGHT, vx: 0, vy: 0, width: MIA_WIDTH, height: MIA_HEIGHT, isGrounded: true, jumpCount: 0, runFrame: 0, facingRight: true, isBarking: false, barkTimer: 0, barkCooldown: 0, isZoomies: false, zoomiesTimer: 0, lives: newLives, maxLives: MAX_LIVES, invincibilityTimer: 0 }, camera: { x: 0 }, keys: { left: false, right: false, up: false, bark: false }, obstacles: [], platforms: [], collectibles: [], particles: [], floatingTexts: [], backgroundObjects: [], parallaxObjects: [], score: newScore, level: newLevel, generatedUntilX: -500, // Force generation start shakeTimer: 0, lastTime: performance.now(), animationId: 0, levelCompleteProcessed: false }; setScore(newScore); setLives(newLives); soundSystem.current.resume(); soundSystem.current.startMusic(zoneIndex); }; const createParticles = (x: number, y: number, count: number, color: string) => { for (let i = 0; i < count; i++) { gameStateRef.current.particles.push({ id: Math.random(), x, y, vx: (Math.random() - 0.5) * 8, vy: (Math.random() - 0.5) * 8, life: 1.0, color, size: Math.random() * 5 + 3, }); } }; const createFloatingText = (x: number, y: number, text: string, color: string) => { gameStateRef.current.floatingTexts.push({ id: Math.random(), x, y, text, life: 1.0, color, }); }; const tryJump = () => { const state = gameStateRef.current; if (state.player.isGrounded) { soundSystem.current.playJump(); state.player.vy = JUMP_FORCE; state.player.isGrounded = false; state.player.jumpCount = 1; createParticles(state.player.x + MIA_WIDTH/2, state.player.y + MIA_HEIGHT, 5, '#FFF'); } else if (state.player.jumpCount < 2) { soundSystem.current.playJump(); state.player.vy = DOUBLE_JUMP_FORCE; state.player.jumpCount++; createParticles(state.player.x + MIA_WIDTH/2, state.player.y + MIA_HEIGHT, 8, '#FFD700'); } }; const tryBark = () => { const state = gameStateRef.current; const { player } = state; if (player.barkCooldown > 0) return; soundSystem.current.playBark(); player.isBarking = true; player.barkTimer = BARK_DURATION; player.barkCooldown = BARK_COOLDOWN; createFloatingText(player.x, player.y - 20, "WOOF!", "#FFFFFF"); // Check for enemies in range const centerPlayerX = player.x + player.width/2; const centerPlayerY = player.y + player.height/2; for (let i = state.obstacles.length - 1; i >= 0; i--) { const obs = state.obstacles[i]; if (obs.type.includes('cat') || obs.type === 'bird' || obs.type === 'roomba' || obs.type === 'fly') { const obsCenterX = obs.x + obs.width/2; const obsCenterY = obs.y + obs.height/2; const dist = Math.hypot(centerPlayerX - obsCenterX, centerPlayerY - obsCenterY); if (dist < BARK_RANGE) { createParticles(obsCenterX, obsCenterY, 15, '#AAAAAA'); createFloatingText(obsCenterX, obsCenterY - 40, "SCRAM!", "#FFF"); state.score += 200; setScore(state.score); state.obstacles.splice(i, 1); } } } }; const getRandomCollectibleType = (): CollectibleType => { const r = Math.random(); if (r > 0.94) return 'chicken'; if (r > 0.85) return 'steak'; if (r > 0.50) return 'biscuit'; return 'bone'; }; // --- World Generation --- const generateWorldChunk = (startX: number, endX: number, canvasHeight: number) => { const state = gameStateRef.current; let currentX = startX; // 0=Living, 1=Kitchen, 2=Garden, 3=Bedroom, 4=Bathroom const zoneIndex = (state.level - 1) % 5; // --- Sky/Parallax Objects --- for (let px = startX; px < endX; px += 300 + Math.random() * 400) { if (Math.random() < 0.3) { state.parallaxObjects.push({ id: Math.random(), x: px, y: Math.random() * 150, type: 'cloud', width: 150, height: 60, depth: 0.2 }); } if (zoneIndex === 2) { if (Math.random() < 0.4) { state.parallaxObjects.push({ id: Math.random(), x: px, y: canvasHeight - GROUND_HEIGHT - 50, type: 'hill', width: 300, height: 150, depth: 0.5 }); } else if (Math.random() < 0.4) { state.parallaxObjects.push({ id: Math.random(), x: px, y: canvasHeight - GROUND_HEIGHT - 250, type: 'tree_bg', width: 200, height: 300, depth: 0.5 }); } } } // Background Objects (Windows, Paintings, Lamps) for (let bgX = startX; bgX < endX; bgX += 200 + Math.random() * 200) { if (zoneIndex !== 2) { // Indoor if (Math.random() < 0.35) { state.backgroundObjects.push({ id: Math.random(), x: bgX, y: 100, type: 'window', variant: 0, width: 100, height: 120 }); } else if (Math.random() < 0.2) { state.backgroundObjects.push({ id: Math.random(), x: bgX, y: 120, type: 'painting', variant: 0, width: 80, height: 60 }); } else if (zoneIndex === 3 && Math.random() < 0.3) { // Bedroom lamp state.backgroundObjects.push({ id: Math.random(), x: bgX, y: 140, type: 'lamp', variant: 0, width: 40, height: 80 }); } else if (zoneIndex === 4 && Math.random() < 0.3) { // Bathroom towel state.backgroundObjects.push({ id: Math.random(), x: bgX, y: 140, type: 'towel_rack', variant: 0, width: 50, height: 40 }); } } else { // Garden if (Math.random() < 0.6) { state.backgroundObjects.push({ id: Math.random(), x: bgX, y: canvasHeight - GROUND_HEIGHT, type: Math.random() > 0.4 ? 'tree' : 'bush', variant: Math.floor(Math.random() * 3) }); } } } while (currentX < endX) { currentX += 100 + Math.random() * 200; const roll = Math.random(); const obstacleChance = 0.85 + (state.level * 0.02); // --- Furniture Generation --- if (roll < 0.45) { const pWidth = PLATFORM_WIDTH_MIN + Math.random() * (PLATFORM_WIDTH_MAX - PLATFORM_WIDTH_MIN); let pHeight = 20; let pType: PlatformType = 'shelf'; let pColor = '#8B0000'; if (zoneIndex === 0) { // Living Room if (Math.random() > 0.5) { pType = 'sofa'; pHeight = SOFA_HEIGHT; pColor = '#8B4513'; } else { pType = 'books'; pHeight = 40; pColor = '#5D4037'; } } else if (zoneIndex === 1) { // Kitchen if (Math.random() > 0.7) { pType = 'fridge'; pHeight = FRIDGE_HEIGHT; pColor = '#FFF'; } else { pType = 'counter'; pHeight = COUNTER_HEIGHT; pColor = '#F0F0F0'; } } else if (zoneIndex === 2) { // Garden pType = 'shelf'; pHeight = 30; pColor = '#8B4513'; // Garden bench/log } else if (zoneIndex === 3) { // Bedroom if (Math.random() > 0.6) { pType = 'bed'; pHeight = BED_HEIGHT; pColor = '#F5DEB3'; } else { pType = 'ottoman'; pHeight = OTTOMAN_HEIGHT; pColor = '#4B0082'; } } else if (zoneIndex === 4) { // Bathroom if (Math.random() > 0.6) { pType = 'bathtub'; pHeight = BATHTUB_HEIGHT; pColor = '#FFF'; } else { pType = 'toilet'; pHeight = TOILET_HEIGHT; pColor = '#FFF'; } } const platY = canvasHeight - GROUND_HEIGHT - pHeight; state.platforms.push({ id: Date.now() + Math.random(), x: currentX, y: platY, width: pWidth, height: pHeight, color: pColor, type: pType }); // Add collectible on top if (Math.random() > 0.3) { const type = getRandomCollectibleType(); const dims = (type === 'chicken') ? {w:CHICKEN_WIDTH, h:CHICKEN_HEIGHT} : (type==='steak'?{w:STEAK_WIDTH, h:STEAK_HEIGHT}:(type==='biscuit'?{w:BISCUIT_WIDTH, h:BISCUIT_HEIGHT}:{w:BONE_WIDTH, h:BONE_HEIGHT})); state.collectibles.push({ id: Date.now() + Math.random(), x: currentX + pWidth / 2 - dims.w/2, y: platY - 60, width: dims.w, height: dims.h, type: type, collected: false, rotation: 0 }); } currentX += pWidth; } else if (roll < obstacleChance) { // --- Obstacle Generation --- const obsRoll = Math.random(); let type: ObstacleType = 'wee'; let vx = 0, vy = 0, w=0, h=0, y=canvasHeight-GROUND_HEIGHT; let initialY = 0, initialX = currentX; let isAsleep = false; // Enemies adapt to room if (zoneIndex === 2) { // Garden if (obsRoll > 0.7) type = 'leaf'; else if (obsRoll > 0.5) type = 'bird'; else if (obsRoll > 0.3) type = 'cat_pouncing'; else type = 'cat_walking'; } else { // Indoors if (zoneIndex === 1 && obsRoll > 0.8) type = 'roomba'; // Kitchen has roombas else if (obsRoll > 0.8) type = 'cat_sleeping'; else if (obsRoll > 0.7) type = 'cat_walking'; else if (obsRoll > 0.5) type = 'poo'; } if (type === 'leaf') { w=LEAF_WIDTH; h=LEAF_HEIGHT; y=-100; vy=LEAF_FALL_SPEED + (state.level*0.2); initialY=Math.random()*100; } else if (type === 'bird') { w=BIRD_WIDTH; h=BIRD_HEIGHT; y=canvasHeight-GROUND_HEIGHT-60-Math.random()*100; vx=-(BIRD_SPEED + (state.level*0.2)); initialY=y; } else if (type === 'roomba') { w=ROOMBA_WIDTH; h=ROOMBA_HEIGHT; y=canvasHeight-GROUND_HEIGHT-h; vx=ROOMBA_SPEED+(state.level*0.3); initialX=currentX; } else if (type === 'wee') { w=WEE_WIDTH_MIN+Math.random()*(WEE_WIDTH_MAX-WEE_WIDTH_MIN); h=WEE_HEIGHT; y=canvasHeight-GROUND_HEIGHT-h; } else if (type === 'poo') { w=POO_WIDTH_MIN+Math.random()*(POO_WIDTH_MAX-POO_WIDTH_MIN); h=35; y=canvasHeight-GROUND_HEIGHT-h; } else if (type.includes('cat')) { w=CAT_WIDTH; if (type === 'cat_sleeping') { h=30; isAsleep = true; } // Lower profile else h=CAT_HEIGHT; y=canvasHeight-GROUND_HEIGHT-h; if (type === 'cat_walking') { vx = -2; initialX = currentX; } } state.obstacles.push({ id: Date.now() + Math.random(), x: currentX, y, width: w, height: h, type, vx, vy, initialY, initialX, patrolRange: 200 + (state.level * 20), swayOffset: Math.random() * Math.PI * 2, hasSpawnedFly: false, isAsleep }); currentX += w; } else { // Ground Collectible const type = getRandomCollectibleType(); const dims = (type === 'chicken') ? {w:CHICKEN_WIDTH, h:CHICKEN_HEIGHT} : (type==='steak'?{w:STEAK_WIDTH, h:STEAK_HEIGHT}:(type==='biscuit'?{w:BISCUIT_WIDTH, h:BISCUIT_HEIGHT}:{w:BONE_WIDTH, h:BONE_HEIGHT})); state.collectibles.push({ id: Date.now() + Math.random(), x: currentX, y: canvasHeight - GROUND_HEIGHT - dims.h - 10, width: dims.w, height: dims.h, type: type, collected: false, rotation: 0 }); currentX += 40; } } state.generatedUntilX = endX; }; // --- Drawing Functions --- const drawSkyLayer = (ctx: CanvasRenderingContext2D, width: number, height: number, cameraX: number, parallaxObjects: ParallaxObject[], zoneIndex: number) => { // Sky Color let skyColor = '#87CEEB'; if (zoneIndex === 3) skyColor = '#191970'; // Midnight blue for bedroom (sleepy time) ctx.fillStyle = skyColor; ctx.fillRect(0, 0, width, height); // Stars for bedroom if (zoneIndex === 3) { ctx.fillStyle = '#FFF'; for(let i=0; i<50; i++) { ctx.beginPath(); ctx.arc((i*100 - cameraX * 0.1) % width, (i * 37) % (height/2), 1, 0, Math.PI*2); ctx.fill(); } } // Draw Parallax Objects (Clouds, Hills, Trees) parallaxObjects.forEach(obj => { const drawX = obj.x - (cameraX * obj.depth); if (drawX < -300 || drawX > width + 300) return; ctx.save(); ctx.translate(drawX, obj.y); if (obj.type === 'cloud') { ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'; ctx.beginPath(); ctx.arc(0, 0, obj.width/2, 0, Math.PI*2); ctx.arc(obj.width*0.4, -obj.height*0.2, obj.width/3, 0, Math.PI*2); ctx.arc(-obj.width*0.4, -obj.height*0.1, obj.width/3, 0, Math.PI*2); ctx.fill(); } else if (obj.type === 'hill') { ctx.fillStyle = '#66CDAA'; ctx.beginPath(); ctx.moveTo(0, obj.height); ctx.quadraticCurveTo(obj.width/2, -obj.height/2, obj.width, obj.height); ctx.fill(); } else if (obj.type === 'tree_bg') { ctx.fillStyle = '#556B2F'; ctx.beginPath(); ctx.moveTo(obj.width/2, 0); ctx.lineTo(obj.width, obj.height); ctx.lineTo(0, obj.height); ctx.fill(); } ctx.restore(); }); }; const drawMia = (ctx: CanvasRenderingContext2D, player: Player, cameraX: number, cust: MiaCustomization) => { const { x, y, width, height, runFrame, isGrounded, facingRight, isBarking, isZoomies, invincibilityTimer } = player; // Invincibility Flash if (invincibilityTimer > 0 && Math.floor(Date.now() / 100) % 2 === 0) return; const drawX = x - cameraX; // Zoomies Trail if (isZoomies) { for(let i=1; i<=3; i++) { ctx.save(); ctx.globalAlpha = 0.3 / i; ctx.translate(drawX - (player.vx * i * 3) + width/2, y + height/2); if (!facingRight) ctx.scale(-1, 1); ctx.translate(-width/2, -height/2); ctx.fillStyle = `hsl(${Date.now()/5 % 360}, 100%, 50%)`; ctx.beginPath(); ctx.ellipse(width/2, height/2, width/2, height/2.2, 0, 0, Math.PI*2); ctx.fill(); ctx.restore(); } } ctx.save(); // Bark Shockwave if (isBarking) { ctx.save(); ctx.translate(drawX + width/2, y + height/2); if (!facingRight) ctx.scale(-1, 1); ctx.strokeStyle = `rgba(255, 255, 255, ${player.barkTimer / BARK_DURATION})`; ctx.lineWidth = 4; for(let i=1; i<=3; i++) { ctx.beginPath(); ctx.arc(40, -10, 10 + (i*10) + ((BARK_DURATION - player.barkTimer)*2), -0.5, 0.5); ctx.stroke(); } ctx.restore(); } ctx.translate(drawX + width / 2, y + height / 2); if (!facingRight) ctx.scale(-1, 1); ctx.translate(-width / 2, -height / 2); const bobY = isGrounded && Math.abs(player.vx) > 0.1 ? Math.sin(runFrame * 0.4) * 3 : 0; // Back Legs const legSpeed = Math.abs(player.vx) * 0.8; const lLegAngle = isGrounded ? Math.sin(runFrame * legSpeed) * 0.5 : 0.5; const rLegAngle = isGrounded ? Math.cos(runFrame * legSpeed) * 0.5 : -0.5; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; // Back legs ctx.lineWidth = 10; ctx.strokeStyle = cust.furColor; ctx.beginPath(); ctx.moveTo(20, height*0.7+bobY); ctx.lineTo(20+Math.sin(rLegAngle)*15, height+2+bobY); ctx.stroke(); ctx.beginPath(); ctx.moveTo(width-20, height*0.7+bobY); ctx.lineTo(width-20+Math.sin(lLegAngle)*15, height+2+bobY); ctx.stroke(); // Tail const tailWag = Math.abs(player.vx) * 2 + Math.sin(Date.now() / 50) * 8; ctx.lineWidth = 6; ctx.strokeStyle = cust.furColor; ctx.beginPath(); ctx.moveTo(5, height * 0.4 + bobY); ctx.quadraticCurveTo(-15, height * 0.2 + bobY, -15, height * 0.1 + bobY + tailWag); ctx.stroke(); // Body ctx.fillStyle = cust.furColor; ctx.beginPath(); ctx.roundRect(5, 10 + bobY, width - 10, height - 20, 20); ctx.fill(); // Shirt if (cust.shirt === 'vest') { ctx.fillStyle = '#4682B4'; ctx.fillRect(15, 12 + bobY, width-30, height-24); } else if (cust.shirt === 'superhero') { ctx.fillStyle = '#FF0000'; ctx.fillRect(15, 12 + bobY, width-30, height-24); ctx.fillStyle = 'yellow'; ctx.beginPath(); ctx.moveTo(width/2, 20+bobY); ctx.lineTo(width/2-5, 30+bobY); ctx.lineTo(width/2+5, 30+bobY); ctx.fill(); } // Zoomies Aura if (isZoomies) { ctx.strokeStyle = `hsl(${Date.now()/2 % 360}, 100%, 70%)`; ctx.lineWidth = 3; ctx.stroke(); } // Collar ctx.fillStyle = cust.collarColor; ctx.beginPath(); ctx.rect(width * 0.65, 10 + bobY, 8, height - 20); ctx.fill(); ctx.fillStyle = 'gold'; // Tag ctx.beginPath(); ctx.arc(width*0.69, height*0.8+bobY, 4, 0, Math.PI*2); ctx.fill(); if (cust.shirt === 'bandana') { ctx.fillStyle = '#FF4500'; ctx.beginPath(); ctx.moveTo(width*0.65, height*0.8+bobY); ctx.lineTo(width*0.65+10, height*0.8+bobY+15); ctx.lineTo(width*0.65-5, height*0.8+bobY+10); ctx.fill(); } // Head ctx.fillStyle = cust.furColor; ctx.beginPath(); ctx.arc(width * 0.85, height * 0.3 + bobY, 26, 0, Math.PI * 2); ctx.fill(); // Ears const earBounce = player.vy * 1.5; ctx.fillStyle = cust.furColor; ctx.beginPath(); ctx.ellipse(width * 0.75, height * 0.15 + bobY - earBounce, 12, 22, Math.PI / 4, 0, Math.PI * 2); ctx.fill(); // Face ctx.fillStyle = 'black'; ctx.beginPath(); ctx.ellipse(width * 0.92, height * 0.25 + bobY, 5, 7, 0, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = 'white'; ctx.beginPath(); ctx.arc(width * 0.94, height * 0.22 + bobY, 2.5, 0, Math.PI * 2); ctx.fill(); // Glasses if (cust.glasses === 'sunglasses') { ctx.fillStyle = 'black'; ctx.fillRect(width*0.85, height*0.2+bobY, 20, 10); ctx.strokeStyle = 'black'; ctx.lineWidth=2; ctx.moveTo(width*0.85, height*0.22+bobY); ctx.lineTo(width*0.75, height*0.22+bobY); ctx.stroke(); } else if (cust.glasses === 'nerd') { ctx.strokeStyle = 'black'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(width*0.92, height*0.25+bobY, 8, 0, Math.PI*2); ctx.stroke(); ctx.beginPath(); ctx.moveTo(width*0.84, height*0.25+bobY); ctx.lineTo(width*0.75, height*0.25+bobY); ctx.stroke(); } else if (cust.glasses === '3d') { ctx.fillStyle = 'red'; ctx.fillRect(width*0.9, height*0.2+bobY, 8, 10); ctx.fillStyle = 'blue'; ctx.fillRect(width*0.82, height*0.2+bobY, 8, 10); } // Nose ctx.fillStyle = 'black'; ctx.beginPath(); ctx.ellipse(width * 1.05, height * 0.35 + bobY, 5, 4, 0, 0, Math.PI * 2); ctx.fill(); // Tongue if (player.vx > 1) { ctx.fillStyle = '#FF69B4'; ctx.beginPath(); ctx.ellipse(width * 1.02, height * 0.5 + bobY, 6, 8, 0.5, 0, Math.PI*2); ctx.fill(); } // Front Legs ctx.lineWidth = 10; ctx.strokeStyle = cust.furColor; ctx.beginPath(); ctx.moveTo(25, height*0.7+bobY); ctx.lineTo(25+Math.sin(lLegAngle)*15, height+2+bobY); ctx.stroke(); ctx.beginPath(); ctx.moveTo(width-25, height*0.7+bobY); ctx.lineTo(width-25+Math.sin(rLegAngle)*15, height+2+bobY); ctx.stroke(); // -- HATS -- if (cust.hat !== 'none') { ctx.save(); ctx.translate(width * 0.85, height * 0.1 + bobY); ctx.rotate(-0.1); if (cust.hat === 'party') { ctx.fillStyle = '#FF4500'; ctx.beginPath(); ctx.moveTo(-15, 0); ctx.lineTo(0, -40); ctx.lineTo(15, 0); ctx.fill(); ctx.fillStyle = 'gold'; ctx.beginPath(); ctx.arc(0, -40, 5, 0, Math.PI*2); ctx.fill(); } else if (cust.hat === 'tophat') { ctx.fillStyle = '#222'; ctx.fillRect(-20, -5, 40, 5); // Brim ctx.fillRect(-15, -35, 30, 30); // Top ctx.fillStyle = 'red'; ctx.fillRect(-15, -10, 30, 5); // Band } else if (cust.hat === 'bow') { ctx.fillStyle = '#FF1493'; ctx.beginPath(); ctx.arc(0, 0, 5, 0, Math.PI*2); ctx.fill(); ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(-15, -10); ctx.lineTo(-15, 10); ctx.fill(); ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(15, -10); ctx.lineTo(15, 10); ctx.fill(); } else if (cust.hat === 'cowboy') { ctx.fillStyle = '#8B4513'; ctx.beginPath(); ctx.ellipse(0, 0, 30, 8, 0, 0, Math.PI*2); ctx.fill(); // Brim ctx.beginPath(); ctx.arc(0, -10, 15, Math.PI, 0); ctx.fill(); // Top } else if (cust.hat === 'crown') { ctx.fillStyle = 'gold'; ctx.beginPath(); ctx.moveTo(-15, 0); ctx.lineTo(-15, -20); ctx.lineTo(-5, -10); ctx.lineTo(0, -25); ctx.lineTo(5, -10); ctx.lineTo(15, -20); ctx.lineTo(15, 0); ctx.fill(); } ctx.restore(); } ctx.restore(); }; const drawObstacle = (ctx: CanvasRenderingContext2D, obs: Obstacle, cameraX: number, playerX: number) => { const drawX = obs.x - cameraX; ctx.save(); if (obs.type === 'leaf') { const t = Date.now() / 200 + (obs.swayOffset || 0); const centerX = drawX + obs.width/2; const centerY = obs.y + obs.height/2; ctx.translate(centerX, centerY); ctx.rotate(Math.sin(t) * 0.5); ctx.fillStyle = '#D2691E'; ctx.beginPath(); ctx.ellipse(0, 0, obs.width/2, obs.height/2, 0, 0, Math.PI*2); ctx.fill(); ctx.strokeStyle = '#8B4500'; ctx.beginPath(); ctx.moveTo(0, -obs.height/2); ctx.lineTo(0, obs.height/2); ctx.stroke(); } else if (obs.type === 'fly') { const t = Date.now() / 50; const centerX = drawX + obs.width/2; const centerY = obs.y + obs.height/2 + Math.sin(t) * 5; ctx.translate(centerX, centerY); ctx.scale(obs.vx && obs.vx > 0 ? -1 : 1, 1); ctx.fillStyle = 'rgba(200, 255, 255, 0.7)'; ctx.beginPath(); ctx.ellipse(-5, -5, 8, 4, Math.sin(t)*0.5, 0, Math.PI*2); ctx.fill(); ctx.beginPath(); ctx.ellipse(5, -5, 8, 4, -Math.sin(t)*0.5, 0, Math.PI*2); ctx.fill(); ctx.fillStyle = 'black'; ctx.beginPath(); ctx.ellipse(0, 0, 6, 5, 0, 0, Math.PI*2); ctx.fill(); ctx.fillStyle = 'red'; ctx.beginPath(); ctx.arc(-3, -2, 2, 0, Math.PI*2); ctx.fill(); } else if (obs.type === 'roomba') { const centerX = drawX + obs.width/2; const centerY = obs.y + obs.height/2; ctx.fillStyle = '#222'; ctx.beginPath(); ctx.ellipse(centerX, centerY + 5, obs.width/2, obs.height/2, 0, 0, Math.PI*2); ctx.fill(); ctx.fillStyle = '#444'; ctx.beginPath(); ctx.ellipse(centerX, centerY, obs.width/2, obs.height/2, 0, 0, Math.PI*2); ctx.fill(); const blink = Math.sin(Date.now() / 100) > 0; ctx.fillStyle = blink ? '#FF0000' : '#880000'; ctx.shadowColor = 'red'; ctx.shadowBlur = blink ? 10 : 0; ctx.beginPath(); ctx.arc(centerX, centerY - 2, 4, 0, Math.PI*2); ctx.fill(); ctx.shadowBlur = 0; } else if (obs.type === 'bird') { const centerY = obs.y + obs.height/2; const centerX = drawX + obs.width/2; const wingOffset = Math.sin(Date.now() / 80) * 10; ctx.fillStyle = '#111'; ctx.beginPath(); ctx.ellipse(centerX, centerY, obs.width/2, obs.height/2, 0, 0, Math.PI*2); ctx.fill(); ctx.beginPath(); ctx.arc(centerX - obs.width/2 - 5, centerY - 5, 12, 0, Math.PI*2); ctx.fill(); ctx.fillStyle = '#FFD700'; ctx.beginPath(); ctx.moveTo(centerX - obs.width/2 - 15, centerY - 5); ctx.lineTo(centerX - obs.width/2 - 25, centerY); ctx.lineTo(centerX - obs.width/2 - 15, centerY + 5); ctx.fill(); ctx.fillStyle = 'white'; ctx.beginPath(); ctx.ellipse(centerX, centerY - 5, 10, 6, 0, 0, Math.PI*2); ctx.fill(); ctx.fillStyle = '#222'; ctx.beginPath(); ctx.moveTo(centerX - 5, centerY - 5); ctx.lineTo(centerX + 20, centerY - 15 - wingOffset); ctx.lineTo(centerX + 10, centerY + 5); ctx.fill(); ctx.fillStyle = 'white'; ctx.beginPath(); ctx.arc(centerX - obs.width/2 - 10, centerY - 8, 3, 0, Math.PI*2); ctx.fill(); ctx.fillStyle = 'black'; ctx.beginPath(); ctx.arc(centerX - obs.width/2 - 11, centerY - 8, 1, 0, Math.PI*2); ctx.fill(); } else if (obs.type.includes('cat')) { // Cat Logic & Drawing const distToPlayer = Math.abs(playerX - obs.x); const isAngry = distToPlayer < 200 && !obs.isAsleep; const centerX = drawX + obs.width/2; const bottomY = obs.y + obs.height; if (obs.type === 'cat_sleeping') { // Sleeping Cat ctx.fillStyle = '#1a1a1a'; ctx.beginPath(); ctx.ellipse(centerX, bottomY - 15, 25, 15, 0, 0, Math.PI*2); ctx.fill(); ctx.beginPath(); ctx.arc(centerX - 10, bottomY - 20, 12, 0, Math.PI*2); ctx.fill(); // Zzz particles if (Math.floor(Date.now()/500) % 2 === 0) { ctx.fillStyle = 'white'; ctx.font = '16px Arial'; ctx.fillText('Zzz', centerX + 10, bottomY - 40); } } else { // Walking or Pouncing Cat with Better Legs const walkCycle = (Date.now() / 150) % Math.PI * 2; const legMove = Math.sin(walkCycle) * 10; ctx.strokeStyle = '#1a1a1a'; ctx.lineWidth = 6; ctx.lineCap = 'round'; // Back Legs ctx.beginPath(); ctx.moveTo(centerX+10, bottomY-25); ctx.lineTo(centerX+15+legMove, bottomY); ctx.stroke(); ctx.beginPath(); ctx.moveTo(centerX-10, bottomY-25); ctx.lineTo(centerX-15-legMove, bottomY); ctx.stroke(); // Body ctx.fillStyle = '#1a1a1a'; ctx.beginPath(); ctx.ellipse(centerX, bottomY - 25, 25, 20, 0, Math.PI, 0); ctx.fill(); // Front Legs ctx.beginPath(); ctx.moveTo(centerX+10, bottomY-25); ctx.lineTo(centerX+15-legMove, bottomY); ctx.stroke(); ctx.beginPath(); ctx.moveTo(centerX-10, bottomY-25); ctx.lineTo(centerX-15+legMove, bottomY); ctx.stroke(); // Head ctx.beginPath(); ctx.arc(centerX - 20, bottomY - 35, 15, 0, Math.PI*2); ctx.fill(); // Ears ctx.beginPath(); ctx.moveTo(centerX - 30, bottomY - 45); ctx.lineTo(centerX - 35, bottomY - 60); ctx.lineTo(centerX - 20, bottomY - 48); ctx.fill(); ctx.beginPath(); ctx.moveTo(centerX - 10, bottomY - 48); ctx.lineTo(centerX - 5, bottomY - 60); ctx.lineTo(centerX - 15, bottomY - 45); ctx.fill(); // Eyes ctx.fillStyle = isAngry || obs.type === 'cat_pouncing' ? '#FF0000' : '#00FF00'; ctx.shadowColor = ctx.fillStyle; ctx.shadowBlur = 10; if (isAngry || Math.floor(Date.now() / 2000) % 2 !== 0) { ctx.beginPath(); ctx.ellipse(centerX - 24, bottomY - 35, 3, isAngry ? 4 : 2, 0.2, 0, Math.PI*2); ctx.fill(); ctx.beginPath(); ctx.ellipse(centerX - 16, bottomY - 35, 3, isAngry ? 4 : 2, -0.2, 0, Math.PI*2); ctx.fill(); } ctx.shadowBlur = 0; if (obs.type === 'cat_pouncing' && obs.pounceTriggered) { ctx.fillStyle = 'red'; ctx.font = 'bold 20px Arial'; ctx.fillText("!!!", centerX, bottomY - 70); } } } else if (obs.type === 'poo') { ctx.fillStyle = '#3E2723'; const piles = 4; const centerX = drawX + obs.width/2; const bottomY = obs.y + obs.height; for(let i=0; i { const drawX = obj.x - cameraX; ctx.save(); ctx.translate(drawX, obj.y); if (obj.type === 'window') { const w = obj.width || 100; const h = obj.height || 100; ctx.fillStyle = '#FFF'; ctx.fillRect(0, 0, w, h); ctx.strokeStyle = '#e0e0e0'; ctx.lineWidth = 2; ctx.strokeRect(0, 0, w, h); } else if (obj.type === 'painting') { const w = obj.width || 80; const h = obj.height || 60; ctx.fillStyle = '#FFE4C4'; ctx.fillRect(0, 0, w, h); ctx.strokeStyle = '#8B4513'; ctx.lineWidth = 4; ctx.strokeRect(0, 0, w, h); ctx.fillStyle = 'orange'; ctx.beginPath(); ctx.arc(w/2, h/2, 10, 0, Math.PI*2); ctx.fill(); } else if (obj.type === 'lamp') { ctx.fillStyle = '#FFD700'; ctx.fillRect(15, 0, 10, 80); // Stand ctx.fillStyle = '#FFA500'; ctx.beginPath(); ctx.moveTo(0, 20); ctx.lineTo(40, 20); ctx.lineTo(35, -10); ctx.lineTo(5, -10); ctx.fill(); // Shade } else if (obj.type === 'towel_rack') { ctx.fillStyle = 'silver'; ctx.fillRect(0, 0, 50, 5); ctx.fillStyle = 'pink'; ctx.fillRect(10, 5, 30, 35); } else if (obj.type === 'cabinet') { const w = obj.width || 120; const h = obj.height || 80; ctx.fillStyle = '#F5DEB3'; ctx.fillRect(0, 0, w, h); ctx.strokeStyle = '#D2691E'; ctx.strokeRect(0, 0, w, h); ctx.beginPath(); ctx.moveTo(w/2, 0); ctx.lineTo(w/2, h); ctx.stroke(); ctx.fillStyle = 'silver'; ctx.beginPath(); ctx.arc(w/2 - 10, h - 20, 4, 0, Math.PI*2); ctx.fill(); ctx.beginPath(); ctx.arc(w/2 + 10, h - 20, 4, 0, Math.PI*2); ctx.fill(); } else if (obj.type === 'tree') { ctx.fillStyle = '#8B4513'; ctx.fillRect(-15, -150, 30, 150); ctx.fillStyle = obj.variant === 0 ? '#228B22' : (obj.variant === 1 ? '#006400' : '#556B2F'); ctx.beginPath(); ctx.arc(0, -160, 60, 0, Math.PI*2); ctx.fill(); ctx.beginPath(); ctx.arc(-40, -140, 50, 0, Math.PI*2); ctx.fill(); ctx.beginPath(); ctx.arc(40, -140, 50, 0, Math.PI*2); ctx.fill(); } else if (obj.type === 'bush') { ctx.fillStyle = obj.variant === 0 ? '#32CD32' : '#2E8B57'; ctx.beginPath(); ctx.arc(0, 0, 30, 0, Math.PI*2, true); ctx.arc(20, -10, 35, 0, Math.PI*2, true); ctx.arc(-20, -10, 35, 0, Math.PI*2, true); ctx.fill(); } else if (obj.type === 'plant_pot') { ctx.fillStyle = '#CD853F'; ctx.beginPath(); ctx.moveTo(-15, 0); ctx.lineTo(-10, -30); ctx.lineTo(10, -30); ctx.lineTo(15, 0); ctx.fill(); ctx.fillStyle = '#228B22'; for(let i=0; i<3; i++) { ctx.save(); ctx.translate(0, -25); ctx.rotate((i-1)*0.5); ctx.beginPath(); ctx.ellipse(0, -20, 10, 20, 0, 0, Math.PI*2); ctx.fill(); ctx.restore(); } } ctx.restore(); }; const drawEnvironment = (ctx: CanvasRenderingContext2D, width: number, height: number, cameraX: number, backgroundObjects: BackgroundObject[], level: number) => { // 0=Living, 1=Kitchen, 2=Garden, 3=Bedroom, 4=Bathroom const zoneIndex = (level - 1) % 5; ctx.save(); ctx.beginPath(); ctx.rect(0, 0, width, height - GROUND_HEIGHT); // Subtract windows backgroundObjects.forEach(obj => { if (obj.type === 'window') { const drawX = obj.x - cameraX; if (drawX > -100 && drawX < width + 100) { const w = obj.width || 100; const h = obj.height || 100; ctx.rect(drawX + 5, obj.y + 5, (w/2)-7, (h/2)-7); ctx.rect(drawX + (w/2)+2, obj.y + 5, (w/2)-7, (h/2)-7); ctx.rect(drawX + 5, obj.y + (h/2)+2, (w/2)-7, (h/2)-7); ctx.rect(drawX + (w/2)+2, obj.y + (h/2)+2, (w/2)-7, (h/2)-7); } } }); ctx.clip("evenodd"); let wallColor = THEME_LIVING_ROOM_WALL; if (zoneIndex === 1) wallColor = THEME_KITCHEN_WALL; if (zoneIndex === 2) wallColor = THEME_GARDEN_WALL; if (zoneIndex === 3) wallColor = THEME_BEDROOM_WALL; if (zoneIndex === 4) wallColor = THEME_BATHROOM_WALL; ctx.fillStyle = wallColor; ctx.fillRect(0, 0, width, height - GROUND_HEIGHT); // Decor Patterns if (zoneIndex === 0) { ctx.fillStyle = 'rgba(200,150,100, 0.1)'; for(let i=0; i { const drawX = plat.x - cameraX; ctx.save(); if (plat.type === 'ottoman') { ctx.fillStyle = plat.color; ctx.beginPath(); ctx.roundRect(drawX, plat.y, plat.width, plat.height, 10); ctx.fill(); ctx.fillStyle = 'rgba(0,0,0,0.2)'; ctx.beginPath(); ctx.arc(drawX + plat.width*0.2, plat.y + 10, 3, 0, Math.PI*2); ctx.fill(); ctx.beginPath(); ctx.arc(drawX + plat.width*0.8, plat.y + 10, 3, 0, Math.PI*2); ctx.fill(); ctx.fillStyle = '#3E2723'; ctx.fillRect(drawX + 10, plat.y + plat.height, 10, 15); ctx.fillRect(drawX + plat.width - 20, plat.y + plat.height, 10, 15); } else if (plat.type === 'counter') { ctx.fillStyle = '#D3D3D3'; ctx.fillRect(drawX, plat.y, plat.width, 10); ctx.fillStyle = '#8B4513'; ctx.fillRect(drawX + 5, plat.y + 10, plat.width - 10, plat.height - 10); ctx.strokeStyle = '#5D4037'; ctx.strokeRect(drawX + 10, plat.y + 15, (plat.width/2)-15, plat.height - 20); ctx.fillStyle = 'silver'; ctx.beginPath(); ctx.arc(drawX + (plat.width/2) - 5, plat.y + 30, 3, 0, Math.PI*2); ctx.fill(); } else if (plat.type === 'sofa') { ctx.fillStyle = plat.color; // Back ctx.beginPath(); ctx.roundRect(drawX, plat.y, plat.width, 40, 10); ctx.fill(); // Seat ctx.beginPath(); ctx.roundRect(drawX - 5, plat.y + 30, plat.width + 10, 20, 5); ctx.fill(); // Arms ctx.beginPath(); ctx.roundRect(drawX - 10, plat.y + 20, 15, 30, 5); ctx.fill(); ctx.beginPath(); ctx.roundRect(drawX + plat.width - 5, plat.y + 20, 15, 30, 5); ctx.fill(); // Legs ctx.fillStyle = '#3E2723'; ctx.fillRect(drawX, plat.y + 50, 10, 20); ctx.fillRect(drawX + plat.width - 10, plat.y + 50, 10, 20); } else if (plat.type === 'fridge') { ctx.fillStyle = 'white'; ctx.fillRect(drawX, plat.y, plat.width, plat.height); ctx.fillStyle = '#EEE'; ctx.fillRect(drawX + 5, plat.y + 5, plat.width - 10, 40); // Freezer line ctx.fillStyle = 'silver'; ctx.fillRect(drawX + 10, plat.y + 50, 5, 40); // Handle } else if (plat.type === 'bed') { ctx.fillStyle = '#8B4513'; ctx.fillRect(drawX, plat.y + 30, plat.width, 50); // Frame ctx.fillStyle = 'white'; ctx.fillRect(drawX + 5, plat.y + 20, 30, 15); // Pillow ctx.fillStyle = '#ADD8E6'; ctx.beginPath(); ctx.roundRect(drawX, plat.y + 35, plat.width, 45, 5); ctx.fill(); // Blanket } else if (plat.type === 'bathtub') { ctx.fillStyle = 'white'; ctx.beginPath(); ctx.roundRect(drawX, plat.y + 20, plat.width, 50, 10); ctx.fill(); ctx.fillStyle = 'silver'; ctx.fillRect(drawX + plat.width - 20, plat.y, 5, 20); // Faucet } else if (plat.type === 'toilet') { ctx.fillStyle = 'white'; ctx.fillRect(drawX + 10, plat.y + 20, 30, 40); // Tank ctx.beginPath(); ctx.arc(drawX + 50, plat.y + 40, 20, 0, Math.PI*2); ctx.fill(); // Bowl } else if (plat.type === 'books') { const colors = ['#800000', '#000080', '#006400']; const bookHeight = plat.height / 3; for(let i=0; i<3; i++) { ctx.fillStyle = colors[i]; ctx.fillRect(drawX + (i*2), plat.y + (i*bookHeight), plat.width - (i*4), bookHeight); ctx.fillStyle = 'gold'; ctx.fillRect(drawX + (i*2) + 5, plat.y + (i*bookHeight) + 5, plat.width - 20, 2); } } else { ctx.fillStyle = plat.color; ctx.fillRect(drawX, plat.y, plat.width, 5); ctx.fillRect(drawX + 10, plat.y, 5, plat.height); ctx.fillRect(drawX + plat.width - 15, plat.y, 5, plat.height); } ctx.restore(); }; const drawCollectible = (ctx: CanvasRenderingContext2D, col: Collectible, cameraX: number) => { const drawX = col.x - cameraX; const wobbleY = Math.sin(performance.now() / 200) * 5; ctx.save(); ctx.translate(drawX + col.width/2, col.y + col.height/2 + wobbleY); ctx.shadowColor = col.type === 'chicken' ? "orange" : "white"; ctx.shadowBlur = col.type === 'chicken' || col.type === 'steak' ? 15 : 0; if (col.type === 'chicken') { ctx.rotate(Math.sin(performance.now() / 500) * 0.1); ctx.fillStyle = '#D2691E'; ctx.beginPath(); ctx.ellipse(0, 5, 22, 16, 0, 0, Math.PI*2); ctx.fill(); ctx.fillStyle = '#FFA07A'; ctx.beginPath(); ctx.ellipse(-5, 2, 8, 4, -0.2, 0, Math.PI*2); ctx.fill(); ctx.fillStyle = '#D2691E'; ctx.beginPath(); ctx.ellipse(-15, -8, 8, 14, -0.6, 0, Math.PI*2); ctx.fill(); ctx.beginPath(); ctx.ellipse(15, -8, 8, 14, 0.6, 0, Math.PI*2); ctx.fill(); ctx.fillStyle = 'white'; ctx.beginPath(); ctx.arc(-22, -18, 4, 0, Math.PI*2); ctx.fill(); ctx.beginPath(); ctx.arc(22, -18, 4, 0, Math.PI*2); ctx.fill(); } else if (col.type === 'steak') { ctx.rotate(Math.sin(performance.now() / 600) * 0.1); ctx.fillStyle = '#8B0000'; ctx.beginPath(); ctx.moveTo(-20, -15); ctx.quadraticCurveTo(0, -5, 20, -15); ctx.quadraticCurveTo(25, 0, 15, 15); ctx.quadraticCurveTo(0, 20, -15, 15); ctx.quadraticCurveTo(-25, 0, -20, -15); ctx.fill(); ctx.strokeStyle = '#E9967A'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(-10, 5); ctx.quadraticCurveTo(0, 0, 10, 8); ctx.stroke(); ctx.fillStyle = '#FFF8DC'; ctx.beginPath(); ctx.fillRect(-4, -15, 8, 20); ctx.fillRect(-15, -15, 30, 6); ctx.fill(); } else if (col.type === 'biscuit') { ctx.rotate(Date.now() / 1000); ctx.fillStyle = '#DEB887'; ctx.beginPath(); ctx.arc(0, 0, 12, 0, Math.PI*2); ctx.fill(); ctx.strokeStyle = '#8B4513'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(0, 0, 8, 0, Math.PI*2); ctx.stroke(); ctx.fillStyle = '#8B4513'; ctx.beginPath(); ctx.arc(-4, -2, 1, 0, Math.PI*2); ctx.fill(); ctx.beginPath(); ctx.arc(4, -2, 1, 0, Math.PI*2); ctx.fill(); ctx.beginPath(); ctx.arc(0, 4, 1, 0, Math.PI*2); ctx.fill(); } else { ctx.rotate(Date.now() / 1000); ctx.fillStyle = COLOR_BONE; ctx.beginPath(); ctx.moveTo(-10, -4); ctx.lineTo(10, -4); ctx.lineTo(10, 4); ctx.lineTo(-10, 4); ctx.fill(); ctx.beginPath(); ctx.arc(-10, -5, 5, 0, Math.PI*2); ctx.fill(); ctx.arc(-10, 5, 5, 0, Math.PI*2); ctx.fill(); ctx.beginPath(); ctx.arc(10, -5, 5, 0, Math.PI*2); ctx.fill(); ctx.arc(10, 5, 5, 0, Math.PI*2); ctx.fill(); } ctx.restore(); }; // --- Main Loop --- useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; const resize = () => { const parent = canvas.parentElement; if (parent) { canvas.width = parent.clientWidth; canvas.height = parent.clientHeight; } }; window.addEventListener('resize', resize); resize(); // Logic to resume or reset if (gameStatus === GameStatus.PLAYING) { if (!gameStateRef.current.levelCompleteProcessed) { soundSystem.current.resume(); // Ensure music is playing for current level const zoneIndex = (gameStateRef.current.level - 1) % 5; soundSystem.current.startMusic(zoneIndex); } else { gameStateRef.current.levelCompleteProcessed = false; resetGame(canvas, true); } } else if (gameStatus === GameStatus.START_SCREEN) { resetGame(canvas); soundSystem.current.stopMusic(); } else if (gameStatus === GameStatus.PAUSED) { soundSystem.current.stopMusic(); } const loop = (timestamp: number) => { // Attractor Mode if (gameStatus === GameStatus.START_SCREEN || gameStatus === GameStatus.CUSTOMIZE) { drawSkyLayer(ctx, canvas.width, canvas.height, 0, gameStateRef.current.parallaxObjects, 0); drawEnvironment(ctx, canvas.width, canvas.height, 0, gameStateRef.current.backgroundObjects, 1); gameStateRef.current.player.y = canvas.height - GROUND_HEIGHT - MIA_HEIGHT; gameStateRef.current.player.x = canvas.width/2 - MIA_WIDTH/2; drawMia(ctx, gameStateRef.current.player, 0, customization); gameStateRef.current.animationId = requestAnimationFrame(loop); return; } if (gameStatus === GameStatus.PAUSED) { // Draw frozen frame (conceptually) - actually just skip update but draw once? // For simplicity, we just won't update physics, but we keep drawing to avoid flicker // Actually, let's just draw the last frame state const state = gameStateRef.current; drawSkyLayer(ctx, canvas.width, canvas.height, state.camera.x, state.parallaxObjects, (state.level - 1)%5); drawEnvironment(ctx, canvas.width, canvas.height, state.camera.x, state.backgroundObjects, state.level); state.backgroundObjects.forEach(obj => { if (obj.x > state.camera.x - 200 && obj.x < state.camera.x + canvas.width + 200) drawBackgroundObject(ctx, obj, state.camera.x); }); state.platforms.forEach(p => { if(p.x > state.camera.x - 200 && p.x < state.camera.x + canvas.width + 200) drawPlatform(ctx, p, state.camera.x); }); state.obstacles.forEach(o => { if(o.x > state.camera.x - 200 && o.x < state.camera.x + canvas.width + 200) drawObstacle(ctx, o, state.camera.x, state.player.x); }); state.collectibles.forEach(c => { if(c.x > state.camera.x - 200 && c.x < state.camera.x + canvas.width + 200) drawCollectible(ctx, c, state.camera.x); }); drawMia(ctx, state.player, state.camera.x, customization); gameStateRef.current.animationId = requestAnimationFrame(loop); return; } if (gameStatus === GameStatus.GAME_OVER || gameStatus === GameStatus.LEVEL_COMPLETE) return; const state = gameStateRef.current; const { player, keys } = state; // --- Screen Shake --- ctx.save(); if (state.shakeTimer > 0) { const shakeX = (Math.random() - 0.5) * SHAKE_INTENSITY; const shakeY = (Math.random() - 0.5) * SHAKE_INTENSITY; ctx.translate(shakeX, shakeY); state.shakeTimer--; } // --- Level Complete Check --- if (state.score > state.level * LEVEL_SCORE_THRESHOLD) { state.levelCompleteProcessed = true; state.level++; soundSystem.current.playLevelComplete(); soundSystem.current.stopMusic(); setGameStatus(GameStatus.LEVEL_COMPLETE); return; } // --- Physics --- const levelSpeedBonus = (state.level - 1) * SPEED_INCREASE_PER_LEVEL; const baseMaxSpeed = MAX_SPEED_BASE + levelSpeedBonus; const currentMaxSpeed = player.isZoomies ? baseMaxSpeed * ZOOMIES_SPEED_MULTIPLIER : baseMaxSpeed; if (keys.left) { player.vx -= ACCELERATION; player.facingRight = false; } if (keys.right) { player.vx += ACCELERATION; player.facingRight = true; } player.vx *= FRICTION; if (player.vx > currentMaxSpeed) player.vx = currentMaxSpeed; if (player.vx < -currentMaxSpeed) player.vx = -currentMaxSpeed; if (Math.abs(player.vx) < 0.1) player.vx = 0; player.x += player.vx; if (Math.abs(player.vx) > 0.1) state.player.runFrame++; player.vy += GRAVITY; player.y += player.vy; if (player.barkTimer > 0) player.barkTimer--; if (player.barkCooldown > 0) player.barkCooldown--; if (player.barkTimer <= 0) player.isBarking = false; if (player.zoomiesTimer > 0) { player.zoomiesTimer--; if (player.zoomiesTimer <= 0) player.isZoomies = false; } if (player.invincibilityTimer > 0) player.invincibilityTimer--; // --- Collisions --- let landed = false; state.platforms.forEach(p => { if ( player.vy >= 0 && player.y + player.height <= p.y + player.vy + 5 && player.y + player.height >= p.y - 10 && player.x + player.width * 0.6 > p.x && player.x + player.width * 0.4 < p.x + p.width ) { player.y = p.y - player.height; player.vy = 0; landed = true; } }); if (!landed) { if (player.y + player.height >= canvas.height - GROUND_HEIGHT) { player.y = canvas.height - GROUND_HEIGHT - player.height; player.vy = 0; landed = true; } } player.isGrounded = landed; if (landed) player.jumpCount = 0; // --- Camera --- const targetCamX = player.x - canvas.width * 0.3; state.camera.x += (targetCamX - state.camera.x) * 0.1; if (state.camera.x < 0) state.camera.x = 0; // --- World Gen --- if (state.camera.x + canvas.width > state.generatedUntilX - 500) { generateWorldChunk(state.generatedUntilX, state.generatedUntilX + CHUNK_SIZE, canvas.height); } // --- Interactions --- // Collectibles for (let i = state.collectibles.length - 1; i >= 0; i--) { const c = state.collectibles[i]; if ( !c.collected && player.x < c.x + c.width && player.x + player.width > c.x && player.y < c.y + c.height && player.y + player.height > c.y ) { c.collected = true; let bonus = 100; let label = "+100"; let color = '#FFF'; soundSystem.current.playCollect(c.type === 'chicken' || c.type === 'steak'); if (c.type === 'chicken') { bonus = 1000; label = "ZOOMIES!"; color = '#FFA500'; player.isZoomies = true; player.zoomiesTimer = ZOOMIES_DURATION; } else if (c.type === 'steak') { bonus = 500; label = "+1 Life!"; color = '#FF4444'; if (player.lives < player.maxLives) { player.lives++; setLives(player.lives); } } else if (c.type === 'biscuit') { bonus = 250; label = "+250"; color = '#D2B48C'; } state.score += bonus; setScore(state.score); createParticles(c.x + c.width/2, c.y + c.height/2, 10, color); createFloatingText(player.x, player.y - 40, label, color); } if (c.x < state.camera.x - 1000 || c.collected) state.collectibles.splice(i, 1); } // Obstacles for (let i = state.obstacles.length - 1; i >= 0; i--) { const obs = state.obstacles[i]; if (obs.x < state.camera.x - 1000) { state.obstacles.splice(i, 1); continue; } // Fly Logic if (obs.type === 'poo' && !obs.hasSpawnedFly) { const dist = Math.hypot(player.x - obs.x, player.y - obs.y); if (dist < 300 && dist > 50) { if (Math.random() < 0.4) { obs.hasSpawnedFly = true; state.obstacles.push({ id: Date.now() + Math.random(), x: obs.x + obs.width/2, y: obs.y, width: FLY_WIDTH, height: FLY_HEIGHT, type: 'fly', vx: 0, vy: 0 }); createParticles(obs.x + obs.width/2, obs.y, 5, 'black'); } else { obs.hasSpawnedFly = true; } } } // Cat Logic if (obs.type === 'cat_pouncing' && !obs.pounceTriggered) { const dx = player.x - obs.x; if (Math.abs(dx) < CAT_POUNCE_RANGE && dx < 0) { // Player is to the left (approaching) obs.pounceTriggered = true; obs.vy = CAT_POUNCE_VY; obs.vx = -CAT_POUNCE_VX; // Jump left towards player createFloatingText(obs.x, obs.y - 40, "!", "#FF0000"); } } // Physics for moving obstacles if (obs.type === 'leaf') { obs.y += obs.vy || 1; obs.x += Math.sin((Date.now() / 200) + (obs.swayOffset || 0)) * 2; } else if (obs.type === 'roomba' || obs.type === 'cat_walking') { if (obs.vx && obs.initialX !== undefined) { obs.x += obs.vx; const range = obs.patrolRange || 150; if (obs.x > obs.initialX + range || obs.x < obs.initialX - range) obs.vx *= -1; } } else if (obs.type === 'fly') { const dx = (player.x + player.width/2) - (obs.x + obs.width/2); const dy = (player.y + player.height/2) - (obs.y + obs.height/2); const angle = Math.atan2(dy, dx); obs.vx = Math.cos(angle) * FLY_SPEED; const vy = Math.sin(angle) * FLY_SPEED; obs.x += obs.vx; obs.y += vy; } else if (obs.type === 'cat_pouncing' && obs.pounceTriggered) { obs.x += obs.vx || 0; obs.y += obs.vy || 0; obs.vy = (obs.vy || 0) + GRAVITY; if (obs.y + obs.height >= canvas.height - GROUND_HEIGHT) { obs.y = canvas.height - GROUND_HEIGHT - obs.height; obs.vy = 0; obs.vx = 0; } } else if (obs.type === 'bird') { obs.x += obs.vx || 0; obs.y = (obs.initialY || obs.y) + Math.sin(Date.now() / 200) * 30; } // Hitbox let padding = 8; if (['wee', 'poo', 'leaf', 'bird', 'roomba', 'fly'].includes(obs.type)) padding = 2; const px = player.x + 20; const py = player.y + 10; const pw = player.width - 40; const ph = player.height - 15; if (px < obs.x + obs.width - padding && px + pw > obs.x + padding && py < obs.y + obs.height - padding && py + ph > obs.y + padding) { if (player.isZoomies) { createParticles(obs.x + obs.width/2, obs.y + obs.height/2, 10, '#FFF'); createFloatingText(obs.x + obs.width/2, obs.y, "SMASH!", "#FF0000"); state.obstacles.splice(i, 1); state.score += 50; setScore(state.score); soundSystem.current.playCollect(false); } else if (player.invincibilityTimer <= 0) { player.lives--; setLives(player.lives); player.invincibilityTimer = INVINCIBILITY_FRAMES; state.shakeTimer = SHAKE_DURATION; soundSystem.current.playDamage(); player.vy = -6; player.vx = player.facingRight ? -4 : 4; createParticles(player.x + player.width/2, player.y + player.height/2, 10, '#FF0000'); createFloatingText(player.x, player.y - 40, "OUCH!", "#FF0000"); if (player.lives <= 0) { soundSystem.current.stopMusic(); setGameStatus(GameStatus.GAME_OVER); } } } } // Particles & Text for (let i = state.particles.length - 1; i >= 0; i--) { const p = state.particles[i]; p.x += p.vx; p.y += p.vy; p.life -= 0.03; if (p.life <= 0) state.particles.splice(i, 1); } for (let i = state.floatingTexts.length - 1; i >= 0; i--) { const t = state.floatingTexts[i]; t.y -= 1.5; t.life -= 0.02; if(t.life <= 0) state.floatingTexts.splice(i, 1); } // --- Render --- ctx.clearRect(0, 0, canvas.width, canvas.height); const currentZone = (state.level - 1) % 5; drawSkyLayer(ctx, canvas.width, canvas.height, state.camera.x, state.parallaxObjects, currentZone); drawEnvironment(ctx, canvas.width, canvas.height, state.camera.x, state.backgroundObjects, state.level); // Wall Decor & Frames state.backgroundObjects.forEach(obj => { if (obj.x > state.camera.x - 200 && obj.x < state.camera.x + canvas.width + 200) { drawBackgroundObject(ctx, obj, state.camera.x); } }); // Gameplay Layer state.platforms.forEach(p => { if(p.x > state.camera.x - 200 && p.x < state.camera.x + canvas.width + 200) drawPlatform(ctx, p, state.camera.x); }); state.obstacles.forEach(o => { if(o.x > state.camera.x - 200 && o.x < state.camera.x + canvas.width + 200) drawObstacle(ctx, o, state.camera.x, player.x); }); state.collectibles.forEach(c => { if(c.x > state.camera.x - 200 && c.x < state.camera.x + canvas.width + 200) drawCollectible(ctx, c, state.camera.x); }); drawMia(ctx, player, state.camera.x, customization); state.particles.forEach(p => { ctx.globalAlpha = p.life; ctx.fillStyle = p.color; ctx.beginPath(); ctx.arc(p.x - state.camera.x, p.y, p.size, 0, Math.PI*2); ctx.fill(); ctx.globalAlpha = 1.0; }); state.floatingTexts.forEach(t => { ctx.globalAlpha = t.life; ctx.fillStyle = t.color; ctx.font = "bold 24px Fredoka"; ctx.strokeStyle = 'black'; ctx.lineWidth = 3; ctx.strokeText(t.text, t.x - state.camera.x, t.y); ctx.fillText(t.text, t.x - state.camera.x, t.y); ctx.globalAlpha = 1.0; }); ctx.restore(); // Restore shake gameStateRef.current.animationId = requestAnimationFrame(loop); }; const handleKeyDown = (e: KeyboardEvent) => { if (e.code === 'Escape' && gameStatus === GameStatus.PLAYING) setGameStatus(GameStatus.PAUSED); else if (e.code === 'Escape' && gameStatus === GameStatus.PAUSED) setGameStatus(GameStatus.PLAYING); if (gameStatus !== GameStatus.PLAYING) return; if (e.code === 'ArrowLeft' || e.code === 'KeyA') gameStateRef.current.keys.left = true; if (e.code === 'ArrowRight' || e.code === 'KeyD') gameStateRef.current.keys.right = true; if ((e.code === 'Space' || e.code === 'ArrowUp' || e.code === 'KeyW')) tryJump(); if (e.code === 'KeyB' || e.code === 'Enter') tryBark(); }; const handleKeyUp = (e: KeyboardEvent) => { if (e.code === 'ArrowLeft' || e.code === 'KeyA') gameStateRef.current.keys.left = false; if (e.code === 'ArrowRight' || e.code === 'KeyD') gameStateRef.current.keys.right = false; }; window.addEventListener('keydown', handleKeyDown); window.addEventListener('keyup', handleKeyUp); // Initial start logic if (gameStatus === GameStatus.PLAYING) { // Loop is started by useEffect gameStateRef.current.animationId = requestAnimationFrame(loop); } else { gameStateRef.current.animationId = requestAnimationFrame(loop); } return () => { window.removeEventListener('resize', resize); window.removeEventListener('keydown', handleKeyDown); window.removeEventListener('keyup', handleKeyUp); cancelAnimationFrame(gameStateRef.current.animationId); soundSystem.current.stopMusic(); }; }, [gameStatus, setGameStatus, setScore, setLives, customization]); return (
e.preventDefault()}> {/* Mobile On-Screen Controls */} {gameStatus === GameStatus.PLAYING && ( <>
)}
); }; export default GameCanvas;