Files
Mias-cloud-run/components/GameCanvas.tsx
Anthony 77f803f26e 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.
2025-11-23 19:32:47 +08:00

1552 lines
68 KiB
TypeScript

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<HTMLCanvasElement>;
customization: MiaCustomization;
}
const GameCanvas: React.FC<GameCanvasProps> = ({ 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<piles; i++) {
const w = obs.width * (1 - i*0.15); const y = bottomY - (i*7) - 5; ctx.beginPath(); ctx.ellipse(centerX, y, w/2, 8, 0, 0, Math.PI*2); ctx.fill();
}
ctx.strokeStyle = 'rgba(100, 200, 50, 0.4)'; ctx.lineWidth = 2; const t = Date.now()/500;
ctx.beginPath(); ctx.moveTo(centerX, bottomY - 35); ctx.quadraticCurveTo(centerX + Math.sin(t)*10, bottomY-50, centerX, bottomY-60); ctx.stroke();
ctx.beginPath(); ctx.moveTo(centerX - 10, bottomY - 20); ctx.quadraticCurveTo(centerX - 10 + Math.sin(t + 1)*8, bottomY-40, centerX - 10, bottomY-50); ctx.stroke();
const flyT = Date.now() / 200; ctx.fillStyle = 'black';
for(let i=0; i<3; i++) {
const fx = centerX + Math.cos(flyT + i*2) * 15; const fy = bottomY - 30 + Math.sin(flyT + i*2) * 10; ctx.beginPath(); ctx.arc(fx, fy, 1.5, 0, Math.PI*2); ctx.fill();
}
} else {
const centerX = drawX + obs.width/2;
const centerY = obs.y + obs.height/2;
ctx.fillStyle = 'rgba(255, 215, 0, 0.7)'; ctx.beginPath(); ctx.ellipse(centerX, centerY, obs.width/2, obs.height/2, 0, 0, Math.PI*2); ctx.ellipse(centerX - 10, centerY + 2, obs.width/3, obs.height/2, 0.2, 0, Math.PI*2); ctx.ellipse(centerX + 15, centerY - 2, obs.width/4, obs.height/3, -0.3, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = 'rgba(255,255,200,0.6)'; const bubbleT = Date.now() / 300; ctx.beginPath(); ctx.ellipse(centerX - 10, centerY - 2, 5, 3, 0, 0, Math.PI*2); ctx.fill(); ctx.beginPath(); ctx.ellipse(centerX + 10, centerY + 3, 4, 2, 0, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = 'rgba(255, 255, 0, 0.4)'; const floatY = (Date.now() / 20) % 50; ctx.beginPath(); ctx.arc(centerX, centerY - floatY, 2 * (1 - floatY/50), 0, Math.PI*2); ctx.fill();
}
ctx.restore();
};
const drawBackgroundObject = (ctx: CanvasRenderingContext2D, obj: BackgroundObject, cameraX: number) => {
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<width; i+=50) ctx.fillRect(i, 0, 20, height - GROUND_HEIGHT);
} else if (zoneIndex === 1 || zoneIndex === 4) {
ctx.strokeStyle = 'rgba(0,0,0,0.05)';
for(let i=0; i<width; i+=50) {
for(let j=0; j<height-GROUND_HEIGHT; j+=50) ctx.strokeRect(i, j, 50, 50);
}
} else if (zoneIndex === 3) {
ctx.fillStyle = 'rgba(255,255,255,0.2)';
for(let i=0; i<width; i+=80) ctx.beginPath(); ctx.arc(i, 100, 20, 0, Math.PI*2); ctx.fill();
}
ctx.restore();
// Floor
let floorColor = THEME_LIVING_ROOM_FLOOR;
if (zoneIndex === 1) floorColor = THEME_KITCHEN_FLOOR;
if (zoneIndex === 2) floorColor = THEME_GARDEN_FLOOR;
if (zoneIndex === 3) floorColor = THEME_BEDROOM_FLOOR;
if (zoneIndex === 4) floorColor = THEME_BATHROOM_FLOOR;
ctx.fillStyle = floorColor;
ctx.fillRect(0, height - GROUND_HEIGHT, width, GROUND_HEIGHT);
// Fence for garden
if (zoneIndex === 2) {
ctx.fillStyle = '#8B4513';
for(let i=0; i<width; i+=60) ctx.fillRect(i, height - GROUND_HEIGHT - 80, 10, 80);
}
};
const drawPlatform = (ctx: CanvasRenderingContext2D, plat: Platform, cameraX: number) => {
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 (
<div className="relative w-full h-full select-none" onContextMenu={(e) => e.preventDefault()}>
<canvas ref={canvasRef} className="block w-full h-full" />
{/* Mobile On-Screen Controls */}
{gameStatus === GameStatus.PLAYING && (
<>
<div className="absolute bottom-8 left-8 flex gap-4 z-50">
<button
className="w-20 h-20 bg-white/30 backdrop-blur-md rounded-full border-2 border-white/50 flex items-center justify-center active:bg-white/50 active:scale-95 transition-all touch-none shadow-lg"
onPointerDown={() => gameStateRef.current.keys.left = true}
onPointerUp={() => gameStateRef.current.keys.left = false}
onPointerLeave={() => gameStateRef.current.keys.left = false}
>
<ArrowLeft className="w-10 h-10 text-white drop-shadow-md" />
</button>
<button
className="w-20 h-20 bg-white/30 backdrop-blur-md rounded-full border-2 border-white/50 flex items-center justify-center active:bg-white/50 active:scale-95 transition-all touch-none shadow-lg"
onPointerDown={() => gameStateRef.current.keys.right = true}
onPointerUp={() => gameStateRef.current.keys.right = false}
onPointerLeave={() => gameStateRef.current.keys.right = false}
>
<ArrowRight className="w-10 h-10 text-white drop-shadow-md" />
</button>
</div>
<div className="absolute bottom-8 right-8 z-50 flex flex-col gap-4 items-end">
<button
className="w-16 h-16 bg-blue-500/50 backdrop-blur-md rounded-full border-4 border-blue-300/50 flex items-center justify-center active:bg-blue-600/60 active:scale-95 transition-all touch-none shadow-xl mb-2 mr-4"
onPointerDown={tryBark}
>
<Megaphone className="w-8 h-8 text-white drop-shadow-md" />
</button>
<button
className="w-24 h-24 bg-amber-500/40 backdrop-blur-md rounded-full border-4 border-amber-200/50 flex items-center justify-center active:bg-amber-600/60 active:scale-95 transition-all touch-none shadow-xl"
onPointerDown={tryJump}
>
<ArrowUp className="w-12 h-12 text-white drop-shadow-md" />
</button>
</div>
</>
)}
</div>
);
};
export default GameCanvas;