diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/App.tsx b/App.tsx new file mode 100644 index 0000000..ce39716 --- /dev/null +++ b/App.tsx @@ -0,0 +1,383 @@ + +import React, { useState, useRef, useEffect } from 'react'; +import GameCanvas from './components/GameCanvas'; +import { GameStatus, MiaCustomization } from './types'; +import { Play, RotateCcw, Trophy, Heart, ArrowRight, Sparkles, Palette, Pause, Home } from 'lucide-react'; +import { MAX_LIVES, FUR_COLORS, COLLAR_COLORS } from './constants'; + +const App: React.FC = () => { + const [gameStatus, setGameStatus] = useState(GameStatus.START_SCREEN); + const [score, setScore] = useState(0); + const [lives, setLives] = useState(MAX_LIVES); + const [highScore, setHighScore] = useState(0); + const [showDamageOverlay, setShowDamageOverlay] = useState(false); + const [customizationTab, setCustomizationTab] = useState<'body' | 'head' | 'style'>('body'); + const [customization, setCustomization] = useState({ + furColor: FUR_COLORS.RUSTY, + collarColor: COLLAR_COLORS.RED, + hat: 'none', + glasses: 'none', + shirt: 'none' + }); + + const prevLivesRef = useRef(MAX_LIVES); + const canvasRef = useRef(null); + + // Trigger damage overlay when lives decrease + useEffect(() => { + if (lives < prevLivesRef.current) { + setShowDamageOverlay(true); + const t = setTimeout(() => setShowDamageOverlay(false), 300); + return () => clearTimeout(t); + } + prevLivesRef.current = lives; + }, [lives]); + + const handleStart = () => { + setGameStatus(GameStatus.PLAYING); + setLives(MAX_LIVES); + prevLivesRef.current = MAX_LIVES; + }; + + const handleRestart = () => { + if (score > highScore) { + setHighScore(score); + } + // Fully reset logic + setGameStatus(GameStatus.START_SCREEN); + setScore(0); + setLives(MAX_LIVES); + prevLivesRef.current = MAX_LIVES; + }; + + const handleNextLevel = () => { + setGameStatus(GameStatus.PLAYING); + }; + + const updateStatus = (status: GameStatus) => { + if (status === GameStatus.GAME_OVER) { + if (score > highScore) setHighScore(score); + } + setGameStatus(status); + }; + + return ( +
+ + {/* Game Layer */} +
+ +
+ + {/* Damage Overlay */} +
+ + {/* UI Layer - HUD */} + {(gameStatus === GameStatus.PLAYING || gameStatus === GameStatus.LEVEL_COMPLETE || gameStatus === GameStatus.PAUSED) && ( +
+
+ {/* Score */} +
+ Score + {score.toString().padStart(5, '0')} +
+ + {/* Lives */} +
+ {Array.from({length: MAX_LIVES}).map((_, i) => ( + + ))} +
+
+ +
+ {/* Best Score */} +
+ + Best + + {highScore.toString().padStart(5, '0')} +
+ + {/* Pause Button */} + +
+
+ )} + + {/* UI Layer - Pause Menu */} + {gameStatus === GameStatus.PAUSED && ( +
+
+

PAUSED

+ +
+ + +
+
+
+ )} + + {/* UI Layer - Start Screen */} + {gameStatus === GameStatus.START_SCREEN && ( +
+
+
+
+ 🐶 +
+
+

Mia's Clean Run

+

Clean the house and avoid the cats!

+ +
+ + + +
+
+
+ )} + + {/* UI Layer - Customization Screen */} + {gameStatus === GameStatus.CUSTOMIZE && ( +
+
+
+

Style Your Pup!

+
+ + {/* Tabs */} +
+ + + +
+ +
+ {customizationTab === 'body' && ( + <> +
+

Fur Color

+
+ {Object.entries(FUR_COLORS).map(([name, color]) => ( +
+
+
+

Collar Color

+
+ {Object.entries(COLLAR_COLORS).map(([name, color]) => ( +
+
+ + )} + + {customizationTab === 'head' && ( + <> +
+

Hats

+
+ {[ + {id: 'none', label: 'None', icon: '🚫'}, + {id: 'party', label: 'Party', icon: '🎉'}, + {id: 'tophat', label: 'Fancy', icon: '🎩'}, + {id: 'bow', label: 'Bow', icon: '🎀'}, + {id: 'cowboy', label: 'Cowboy', icon: '🤠'}, + {id: 'crown', label: 'Royal', icon: '👑'}, + ].map((item) => ( + + ))} +
+
+
+

Glasses

+
+ {[ + {id: 'none', label: 'None', icon: '🚫'}, + {id: 'sunglasses', label: 'Cool', icon: '😎'}, + {id: 'nerd', label: 'Smart', icon: '🤓'}, + {id: '3d', label: 'Movie', icon: '🍿'}, + ].map((item) => ( + + ))} +
+
+ + )} + + {customizationTab === 'style' && ( +
+

Outfits

+
+ {[ + {id: 'none', label: 'Au Naturel', icon: '🐕'}, + {id: 'bandana', label: 'Bandana', icon: '🧣'}, + {id: 'vest', label: 'Service Vest', icon: '🦺'}, + {id: 'superhero', label: 'Hero Cape', icon: '🦸'}, + ].map((item) => ( + + ))} +
+
+ )} +
+ +
+ +
+
+
+ )} + + {/* UI Layer - Level Complete */} + {gameStatus === GameStatus.LEVEL_COMPLETE && ( +
+
+ +

Level Complete!

+

Mia is a good girl!

+ +
+
Current Score
+
{score}
+
+ + +
+
+ )} + + {/* UI Layer - Game Over */} + {gameStatus === GameStatus.GAME_OVER && ( +
+
+

Oops!

+

Mia ran out of lives!

+ +
+
Final Score
+
{score}
+
+ +
+ + +
+
+
+ )} + +
+ ); +}; + +export default App; diff --git a/README.md b/README.md index 2241000..ff5d6eb 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,20 @@
- GHBanner - -

Built with AI Studio

- -

The fastest path from prompt to production with Gemini.

- - Start building -
+ +# Run and deploy your AI Studio app + +This contains everything you need to run your app locally. + +View your app in AI Studio: https://ai.studio/apps/drive/1hILMyAxAYnMmNvWuSd6JAz1fmey0teWE + +## Run Locally + +**Prerequisites:** Node.js + + +1. Install dependencies: + `npm install` +2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key +3. Run the app: + `npm run dev` diff --git a/components/GameCanvas.tsx b/components/GameCanvas.tsx new file mode 100644 index 0000000..da932a8 --- /dev/null +++ b/components/GameCanvas.tsx @@ -0,0 +1,1551 @@ + +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; diff --git a/constants.ts b/constants.ts new file mode 100644 index 0000000..5edb487 --- /dev/null +++ b/constants.ts @@ -0,0 +1,120 @@ + +// Physics & World +export const GRAVITY = 0.6; +export const JUMP_FORCE = -15; +export const DOUBLE_JUMP_FORCE = -12; +export const GROUND_HEIGHT = 80; +export const ACCELERATION = 0.5; +export const FRICTION = 0.85; +export const MAX_SPEED_BASE = 9; // Renamed from MAX_SPEED +export const ZOOMIES_SPEED_MULTIPLIER = 1.6; + +// Progression +export const LEVEL_SCORE_THRESHOLD = 4000; // Increased significantly for longer levels +export const SPEED_INCREASE_PER_LEVEL = 0.5; + +// Visual Feedback +export const SHAKE_INTENSITY = 15; +export const SHAKE_DURATION = 20; + +// World Generation +export const CHUNK_SIZE = 1200; // How much world creates at once + +// Player Dimensions & Stats +export const MIA_WIDTH = 64; +export const MIA_HEIGHT = 44; +export const MAX_LIVES = 3; +export const INVINCIBILITY_FRAMES = 120; // 2 seconds at 60fps +export const ZOOMIES_DURATION = 300; // 5 seconds at 60fps + +// Bark Ability +export const BARK_RANGE = 250; +export const BARK_DURATION = 15; // Frames the visual lasts +export const BARK_COOLDOWN = 60; // Frames until can bark again + +// Obstacle Dimensions +export const WEE_WIDTH_MIN = 50; +export const WEE_WIDTH_MAX = 90; +export const WEE_HEIGHT = 20; +export const POO_WIDTH_MIN = 30; +export const POO_WIDTH_MAX = 50; +export const CAT_WIDTH = 70; // Slightly wider for legs +export const CAT_HEIGHT = 50; +export const CAT_POUNCE_RANGE = 200; +export const CAT_POUNCE_VY = -13; +export const CAT_POUNCE_VX = 6; + +export const BIRD_WIDTH = 45; +export const BIRD_HEIGHT = 30; +export const BIRD_SPEED = 3.5; +export const LEAF_WIDTH = 30; +export const LEAF_HEIGHT = 30; +export const LEAF_FALL_SPEED = 2.5; +export const ROOMBA_WIDTH = 50; +export const ROOMBA_HEIGHT = 20; +export const ROOMBA_SPEED = 2; +export const FLY_WIDTH = 16; +export const FLY_HEIGHT = 12; +export const FLY_SPEED = 3.5; // Chases you slightly slower than running speed + +// Platform Dimensions +export const PLATFORM_HEIGHT = 20; +export const OTTOMAN_HEIGHT = 50; +export const COUNTER_HEIGHT = 120; // For kitchen +export const FRIDGE_HEIGHT = 160; +export const SOFA_HEIGHT = 70; +export const BED_HEIGHT = 80; +export const BATHTUB_HEIGHT = 70; +export const TOILET_HEIGHT = 60; + +export const PLATFORM_WIDTH_MIN = 80; +export const PLATFORM_WIDTH_MAX = 150; + +// Collectibles +export const CHICKEN_WIDTH = 55; +export const CHICKEN_HEIGHT = 35; +export const STEAK_WIDTH = 45; +export const STEAK_HEIGHT = 35; +export const BISCUIT_WIDTH = 30; +export const BISCUIT_HEIGHT = 30; +export const BONE_WIDTH = 30; +export const BONE_HEIGHT = 15; + +// Colors +export const COLOR_WHITE = '#FFFFFF'; +export const COLOR_WEE = '#FFD700'; // Gold +export const COLOR_POO = '#4B3621'; // Warm Black/Brown +export const COLOR_CAT = '#1a1a1a'; // Almost Black +export const COLOR_CHICKEN = '#FFA500'; // Orange/Gold +export const COLOR_BONE = '#F5F5DC'; // Beige + +// Theme Colors +export const THEME_LIVING_ROOM_WALL = '#FFF5E1'; +export const THEME_LIVING_ROOM_FLOOR = '#DEB887'; +export const THEME_KITCHEN_WALL = '#E0FFFF'; // Light Cyan +export const THEME_KITCHEN_FLOOR = '#D3D3D3'; // Light Grey Tiles +export const THEME_GARDEN_WALL = '#87CEEB'; // Sky Blue +export const THEME_GARDEN_FLOOR = '#90EE90'; // Light Green Grass +export const THEME_BEDROOM_WALL = '#E6E6FA'; // Lavender +export const THEME_BEDROOM_FLOOR = '#F5DEB3'; // Wheat/Carpet +export const THEME_BATHROOM_WALL = '#F0F8FF'; // Alice Blue +export const THEME_BATHROOM_FLOOR = '#708090'; // Slate Grey tiles + +// Customization Options +export const FUR_COLORS = { + RUSTY: '#A0522D', + GOLDEN: '#DAA520', + CHOCOLATE: '#5D4037', + OREO: '#333333', + PINK: '#FFB6C1', + WHITE: '#F0F0F0' +}; + +export const COLLAR_COLORS = { + RED: '#FF6347', + BLUE: '#4169E1', + PINK: '#FF69B4', + GREEN: '#32CD32', + BLACK: '#000000', + PURPLE: '#9370DB' +}; diff --git a/index.html b/index.html new file mode 100644 index 0000000..d4b80ad --- /dev/null +++ b/index.html @@ -0,0 +1,30 @@ + + + + + + Mia's Clean Run + + + + + + +
+ + \ No newline at end of file diff --git a/index.tsx b/index.tsx new file mode 100644 index 0000000..6ca5361 --- /dev/null +++ b/index.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; + +const rootElement = document.getElementById('root'); +if (!rootElement) { + throw new Error("Could not find root element to mount to"); +} + +const root = ReactDOM.createRoot(rootElement); +root.render( + + + +); \ No newline at end of file diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..0eaa229 --- /dev/null +++ b/metadata.json @@ -0,0 +1,5 @@ +{ + "name": "Mia's Clean Run", + "description": "Jump over puddles, piles, and cats while collecting treats in this cute domestic adventure.", + "requestFramePermissions": [] +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..fe52004 --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "mia's-clean-run", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react-dom": "^19.2.0", + "react": "^19.2.0", + "lucide-react": "^0.554.0" + }, + "devDependencies": { + "@types/node": "^22.14.0", + "@vitejs/plugin-react": "^5.0.0", + "typescript": "~5.8.2", + "vite": "^6.2.0" + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2c6eed5 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "ES2022", + "experimentalDecorators": true, + "useDefineForClassFields": false, + "module": "ESNext", + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], + "skipLibCheck": true, + "types": [ + "node" + ], + "moduleResolution": "bundler", + "isolatedModules": true, + "moduleDetection": "force", + "allowJs": true, + "jsx": "react-jsx", + "paths": { + "@/*": [ + "./*" + ] + }, + "allowImportingTsExtensions": true, + "noEmit": true + } +} \ No newline at end of file diff --git a/types.ts b/types.ts new file mode 100644 index 0000000..a9362ac --- /dev/null +++ b/types.ts @@ -0,0 +1,123 @@ + +export interface Player { + x: number; + y: number; + vx: number; // Horizontal velocity + vy: number; // Vertical velocity + width: number; + height: number; + isGrounded: boolean; + jumpCount: number; // For double jump + runFrame: number; + facingRight: boolean; // For sprite flipping + isBarking: boolean; + barkTimer: number; // How long the bark visual lasts + barkCooldown: number; // Time until next bark + isZoomies: boolean; // Invincibility mode + zoomiesTimer: number; + lives: number; + maxLives: number; + invincibilityTimer: number; +} + +export interface MiaCustomization { + furColor: string; + collarColor: string; + hat: 'none' | 'party' | 'tophat' | 'bow' | 'cowboy' | 'crown'; + glasses: 'none' | 'sunglasses' | 'nerd' | '3d'; + shirt: 'none' | 'bandana' | 'vest' | 'superhero'; +} + +export type ObstacleType = 'wee' | 'poo' | 'cat_walking' | 'cat_sleeping' | 'cat_pouncing' | 'bird' | 'leaf' | 'roomba' | 'fly'; + +export interface Obstacle { + id: number; + x: number; + y: number; + width: number; + height: number; + type: ObstacleType; + vx?: number; // Velocity for moving obstacles + vy?: number; // Vertical velocity for falling items + initialY?: number; // Reference for flying patterns + initialX?: number; // Reference for patrolling + patrolRange?: number; // How far to move back and forth + swayOffset?: number; // Random offset for leaf sway/animations + hasSpawnedFly?: boolean; // Track if a fly has emerged from this poo + pounceTriggered?: boolean; // For pouncing cats + isAsleep?: boolean; // For sleeping cats +} + +export type PlatformType = 'ottoman' | 'books' | 'shelf' | 'counter' | 'sofa' | 'fridge' | 'bed' | 'bathtub' | 'toilet' | 'vanity'; + +export interface Platform { + id: number; + x: number; + y: number; + width: number; + height: number; + color: string; + type: PlatformType; +} + +export type CollectibleType = 'chicken' | 'bone' | 'steak' | 'biscuit'; + +export interface Collectible { + id: number; + x: number; + y: number; + width: number; + height: number; + type: CollectibleType; + collected: boolean; + rotation: number; // For animation +} + +export interface Particle { + id: number; + x: number; + y: number; + vx: number; + vy: number; + life: number; + color: string; + size: number; +} + +export interface FloatingText { + id: number; + x: number; + y: number; + text: string; + life: number; + color: string; +} + +export interface ParallaxObject { + id: number; + x: number; + y: number; + type: 'tree_bg' | 'cloud' | 'hill' | 'cityscape'; + width: number; + height: number; + depth: number; // 0.1 (far) to 0.9 (near), affects scroll speed +} + +export interface BackgroundObject { + id: number; + x: number; + y: number; + type: 'tree' | 'bush' | 'plant_pot' | 'window' | 'painting' | 'cabinet' | 'lamp' | 'towel_rack'; + variant: number; + width?: number; // Added for windows + height?: number; // Added for windows +} + +export enum GameStatus { + START_SCREEN = 'START_SCREEN', + CUSTOMIZE = 'CUSTOMIZE', + PLAYING = 'PLAYING', + PAUSED = 'PAUSED', + LEVEL_COMPLETE = 'LEVEL_COMPLETE', + GAME_OVER = 'GAME_OVER' +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..ee5fb8d --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,23 @@ +import path from 'path'; +import { defineConfig, loadEnv } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, '.', ''); + return { + server: { + port: 3000, + host: '0.0.0.0', + }, + plugins: [react()], + define: { + 'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY), + 'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY) + }, + resolve: { + alias: { + '@': path.resolve(__dirname, '.'), + } + } + }; +});