mirror of
https://github.com/Tony0410/Mias-cloud-run.git
synced 2026-05-24 22:01:40 +08:00
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.
1552 lines
68 KiB
TypeScript
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;
|