From 77f803f26e16775d7d50206f588a33f00bf78d94 Mon Sep 17 00:00:00 2001 From: Anthony <47945770+Tony0410@users.noreply.github.com> Date: Sun, 23 Nov 2025 19:32:47 +0800 Subject: [PATCH] feat: Initialize Mia's Clean Run project Sets up the basic project structure, dependencies, and configuration for the game. Includes initial HTML, TypeScript, and Vite configurations. Adds initial types and constants for game mechanics and assets. --- .gitignore | 24 + App.tsx | 383 +++++++++ README.md | 25 +- components/GameCanvas.tsx | 1551 +++++++++++++++++++++++++++++++++++++ constants.ts | 120 +++ index.html | 30 + index.tsx | 15 + metadata.json | 5 + package.json | 22 + tsconfig.json | 29 + types.ts | 123 +++ vite.config.ts | 23 + 12 files changed, 2342 insertions(+), 8 deletions(-) create mode 100644 .gitignore create mode 100644 App.tsx create mode 100644 components/GameCanvas.tsx create mode 100644 constants.ts create mode 100644 index.html create mode 100644 index.tsx create mode 100644 metadata.json create mode 100644 package.json create mode 100644 tsconfig.json create mode 100644 types.ts create mode 100644 vite.config.ts 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, '.'), + } + } + }; +});