39343-vm/games/DanpheRush.tsx
2026-03-27 12:21:43 +00:00

635 lines
23 KiB
TypeScript

import React, { useState, useEffect, useRef } from 'react';
import { Button } from '../components/ui/Button';
import { ArrowLeft, RefreshCw, Volume2, VolumeX, Trophy, Play, Zap, Shield } from 'lucide-react';
import { StorageService } from '../services/storageService';
interface GameProps {
onExit: () => void;
}
export const DanpheRush: React.FC<GameProps> = ({ onExit }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const audioCtxRef = useRef<AudioContext | null>(null);
const [gameState, setGameState] = useState<'READY' | 'PLAYING' | 'GAMEOVER'>('READY');
const [score, setScore] = useState(0);
const [highScore, setHighScore] = useState(0);
const [level, setLevel] = useState(1);
const [audioEnabled, setAudioEnabled] = useState(true);
// Boost State
const [boostUnlocked, setBoostUnlocked] = useState(false);
const [boostCharge, setBoostCharge] = useState(0); // 0 to 3
const [isBoosting, setIsBoosting] = useState(false);
// Constants
const GRAVITY = 0.4;
const JUMP_STRENGTH = -7.5;
const BOOST_STRENGTH = -12;
const BASE_SPEED = 3.5;
const PIPE_WIDTH = 70;
const PIPE_GAP = 170;
const LEVEL_THRESHOLD = 10; // Points per level
const PIPES_FOR_BOOST = 3;
const SPAWN_RATE = 120;
const physics = useRef({
birdY: 200,
velocity: 0,
rotation: 0,
pipes: [] as { x: number; topHeight: number; passed: boolean; broken?: boolean }[],
clouds: [] as { x: number; y: number; scale: number; speed: number }[],
particles: [] as { x: number; y: number; vx: number; vy: number; life: number; color: string }[],
frame: 0,
flashOpacity: 0,
invincibleTimer: 0,
speed: BASE_SPEED,
pipesSinceBoost: 0
});
const requestRef = useRef<number>(0);
useEffect(() => {
const loadData = async () => {
const profile = await StorageService.getProfile();
if (profile?.highScores?.danphe) setHighScore(profile.highScores.danphe);
};
loadData();
initClouds();
// Global Key Listener
const handleKeyDown = (e: KeyboardEvent) => {
if (e.code === 'Space') {
e.preventDefault(); // Prevent scrolling
flap();
}
if (e.code === 'KeyE') {
activateBoost();
}
};
window.addEventListener('keydown', handleKeyDown);
// Start loop
requestRef.current = requestAnimationFrame(loop);
return () => {
cancelAnimationFrame(requestRef.current);
window.removeEventListener('keydown', handleKeyDown);
if (audioCtxRef.current) {
audioCtxRef.current.close().catch(() => {});
}
};
}, [gameState, boostUnlocked, boostCharge]); // Dependencies for key listener state access
const initAudio = () => {
if (!audioCtxRef.current) {
const AudioContext = window.AudioContext || (window as any).webkitAudioContext;
if (AudioContext) {
audioCtxRef.current = new AudioContext();
}
}
if (audioCtxRef.current && audioCtxRef.current.state === 'suspended') {
audioCtxRef.current.resume().catch(() => {});
}
};
const initClouds = () => {
physics.current.clouds = Array.from({ length: 6 }, () => ({
x: Math.random() * 800,
y: Math.random() * 300,
scale: 0.5 + Math.random() * 0.5,
speed: 0.5 + Math.random() * 0.5
}));
};
const playSound = (type: 'flap' | 'score' | 'hit' | 'boost') => {
if (!audioEnabled) return;
// Ensure context exists
if (!audioCtxRef.current) initAudio();
const ctx = audioCtxRef.current;
if (!ctx) return;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
const now = ctx.currentTime;
if (type === 'flap') {
// Snappy Jump Sound
osc.frequency.setValueAtTime(150, now);
osc.frequency.linearRampToValueAtTime(300, now + 0.1);
gain.gain.setValueAtTime(0.2, now);
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.1);
osc.start();
osc.stop(now + 0.1);
} else if (type === 'score') {
osc.type = 'square';
osc.frequency.setValueAtTime(800, now);
osc.frequency.setValueAtTime(1200, now + 0.05);
gain.gain.setValueAtTime(0.1, now);
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.1);
osc.start();
osc.stop(now + 0.1);
} else if (type === 'hit') {
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(150, now);
osc.frequency.linearRampToValueAtTime(100, now + 0.3);
gain.gain.setValueAtTime(0.3, now);
gain.gain.linearRampToValueAtTime(0.01, now + 0.3);
osc.start();
osc.stop(now + 0.3);
} else if (type === 'boost') {
osc.type = 'sine';
osc.frequency.setValueAtTime(200, now);
osc.frequency.linearRampToValueAtTime(800, now + 0.5);
gain.gain.setValueAtTime(0.3, now);
gain.gain.linearRampToValueAtTime(0.01, now + 0.5);
osc.start();
osc.stop(now + 0.5);
}
};
const resetGame = () => {
setGameState('READY');
setScore(0);
setLevel(1);
setBoostUnlocked(false);
setBoostCharge(0);
setIsBoosting(false);
physics.current = {
birdY: 250,
velocity: 0,
rotation: 0,
pipes: [],
clouds: physics.current.clouds,
particles: [],
frame: 0,
flashOpacity: 0,
invincibleTimer: 0,
speed: BASE_SPEED,
pipesSinceBoost: 0
};
};
const flap = () => {
// Resume audio context on user interaction
initAudio();
if (gameState === 'GAMEOVER') return;
if (gameState === 'READY') {
setGameState('PLAYING');
}
physics.current.velocity = JUMP_STRENGTH;
spawnParticles(100, physics.current.birdY, 'white');
playSound('flap');
};
const activateBoost = () => {
// Resume audio context
initAudio();
const P = physics.current;
if (gameState !== 'PLAYING') return;
const currentLevel = Math.floor(score / LEVEL_THRESHOLD) + 1;
// Boost is ready if unlocked (Lvl 3+) AND charge is full
const canBoost = currentLevel > 3 && P.pipesSinceBoost >= PIPES_FOR_BOOST;
if (canBoost) {
P.velocity = BOOST_STRENGTH;
P.invincibleTimer = 60; // 1 second (at 60fps)
P.pipesSinceBoost = 0; // Reset charge
setBoostCharge(0);
setIsBoosting(true);
playSound('boost');
// Explosion effect
for(let i=0; i<20; i++) {
physics.current.particles.push({
x: 100, y: P.birdY,
vx: (Math.random() - 0.5) * 10,
vy: (Math.random() - 0.5) * 10,
life: 1.0,
color: '#facc15'
});
}
}
};
const spawnParticles = (x: number, y: number, color: string) => {
for(let i=0; i<5; i++) {
physics.current.particles.push({
x, y,
vx: (Math.random() - 0.5) * 3 - 2, // Move left mostly
vy: (Math.random() - 0.5) * 3,
life: 1.0,
color
});
}
};
const gameOverLogic = () => {
if (gameState === 'GAMEOVER') return;
setGameState('GAMEOVER');
playSound('hit');
physics.current.flashOpacity = 1.0;
if (score > highScore) {
setHighScore(score);
StorageService.saveHighScore('danphe', score);
}
// Updated addPoints
StorageService.addPoints(score, score * 5, 'game_reward', 'Danphe Rush Score');
};
const loop = () => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const WIDTH = canvas.width;
const HEIGHT = canvas.height;
const P = physics.current;
// --- LOGIC ---
// Level & Unlock Logic
const currentLvl = Math.floor(score / LEVEL_THRESHOLD) + 1;
if (currentLvl !== level) setLevel(currentLvl);
const isUnlocked = currentLvl > 3;
if (isUnlocked !== boostUnlocked) setBoostUnlocked(isUnlocked);
// Sync React state for UI (limited updates to prevent lag)
if (isUnlocked) {
// Clamp charge visual
const charge = Math.min(PIPES_FOR_BOOST, P.pipesSinceBoost);
if (charge !== boostCharge) setBoostCharge(charge);
}
if (isBoosting && P.invincibleTimer <= 0) setIsBoosting(false);
// Difficulty Scaling
P.speed = BASE_SPEED + (currentLvl * 0.2);
// Background movement (Clouds)
if (gameState !== 'GAMEOVER') {
P.clouds.forEach(c => {
c.x -= c.speed * (gameState === 'PLAYING' ? 1 : 0.5);
if (c.x < -100) c.x = WIDTH + 100;
});
}
if (gameState === 'PLAYING') {
P.frame++;
if (P.invincibleTimer > 0) P.invincibleTimer--;
// Physics
P.velocity += GRAVITY;
P.birdY += P.velocity;
// Rotation logic
if (P.velocity < 0) {
P.rotation = Math.max(-0.5, P.rotation - 0.1);
} else {
P.rotation = Math.min(Math.PI / 2, P.rotation + 0.05);
}
// Pipe Spawning
if (P.frame % SPAWN_RATE === 0) {
const minHeight = 100;
const maxHeight = HEIGHT - 150 - PIPE_GAP;
const height = Math.floor(Math.random() * (maxHeight - minHeight + 1)) + minHeight;
P.pipes.push({ x: WIDTH, topHeight: height, passed: false });
}
// Pipe Movement & Collision
P.pipes.forEach(pipe => {
pipe.x -= P.speed;
// Collision AABB
const birdLeft = 100 - 15;
const birdRight = 100 + 15;
const birdTop = P.birdY - 12;
const birdBottom = P.birdY + 12;
const pipeLeft = pipe.x;
const pipeRight = pipe.x + PIPE_WIDTH;
// Hit Pipe
if (!pipe.broken && birdRight > pipeLeft && birdLeft < pipeRight) {
if (birdTop < pipe.topHeight || birdBottom > pipe.topHeight + PIPE_GAP) {
if (P.invincibleTimer > 0) {
// Boost destroys pipe logic (visual only, we just mark it passed/broken)
pipe.broken = true;
spawnParticles(pipe.x, P.birdY, '#64748b'); // Stone debris
setScore(s => s + 2); // Bonus points for smashing
playSound('score');
} else {
gameOverLogic();
}
}
}
// Score & Charge
if (!pipe.passed && birdLeft > pipeRight) {
pipe.passed = true;
setScore(s => s + 1);
// Increment boost charge
if (P.pipesSinceBoost < PIPES_FOR_BOOST) {
P.pipesSinceBoost++;
}
playSound('score');
}
});
// Cleanup pipes
if (P.pipes.length > 0 && P.pipes[0].x < -PIPE_WIDTH) {
P.pipes.shift();
}
// Ground/Ceiling Collision
if (P.birdY >= HEIGHT - 40 || P.birdY < 0) {
if (P.invincibleTimer > 0 && P.birdY < 0) {
// Hitting ceiling while boosting is fine, just clamp
P.birdY = 0;
P.velocity = 0;
} else {
gameOverLogic();
}
}
} else if (gameState === 'READY') {
P.birdY = 250 + Math.sin(Date.now() / 300) * 10;
P.rotation = 0;
} else if (gameState === 'GAMEOVER') {
if (P.birdY < HEIGHT - 40) {
P.velocity += GRAVITY;
P.birdY += P.velocity;
P.rotation = Math.min(Math.PI / 2, P.rotation + 0.1);
}
}
if (P.flashOpacity > 0) P.flashOpacity -= 0.05;
// --- RENDER ---
// Sky
const skyGrad = ctx.createLinearGradient(0, 0, 0, HEIGHT);
skyGrad.addColorStop(0, '#38bdf8'); // Sky blue
skyGrad.addColorStop(1, '#bae6fd');
ctx.fillStyle = skyGrad;
ctx.fillRect(0, 0, WIDTH, HEIGHT);
// Mountains
ctx.fillStyle = '#f1f5f9';
ctx.beginPath();
ctx.moveTo(0, HEIGHT);
ctx.lineTo(200, HEIGHT - 200);
ctx.lineTo(400, HEIGHT - 100);
ctx.lineTo(600, HEIGHT - 250);
ctx.lineTo(800, HEIGHT);
ctx.fill();
// Clouds
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
P.clouds.forEach(c => {
ctx.beginPath();
ctx.arc(c.x, c.y, 30 * c.scale, 0, Math.PI * 2);
ctx.arc(c.x + 20 * c.scale, c.y - 10 * c.scale, 35 * c.scale, 0, Math.PI * 2);
ctx.arc(c.x + 40 * c.scale, c.y, 30 * c.scale, 0, Math.PI * 2);
ctx.fill();
});
// Pipes
P.pipes.forEach(pipe => {
if (pipe.broken) ctx.globalAlpha = 0.5;
// Top Pillar
const gradTop = ctx.createLinearGradient(pipe.x, 0, pipe.x + PIPE_WIDTH, 0);
gradTop.addColorStop(0, '#475569');
gradTop.addColorStop(0.5, '#64748b');
gradTop.addColorStop(1, '#475569');
ctx.fillStyle = gradTop;
ctx.fillRect(pipe.x, 0, PIPE_WIDTH, pipe.topHeight);
// Cap Top
ctx.fillStyle = '#334155';
ctx.fillRect(pipe.x - 4, pipe.topHeight - 20, PIPE_WIDTH + 8, 20);
// Bottom Pillar
ctx.fillStyle = gradTop;
ctx.fillRect(pipe.x, pipe.topHeight + PIPE_GAP, PIPE_WIDTH, HEIGHT - (pipe.topHeight + PIPE_GAP));
// Cap Bottom
ctx.fillStyle = '#334155';
ctx.fillRect(pipe.x - 4, pipe.topHeight + PIPE_GAP, PIPE_WIDTH + 8, 20);
ctx.globalAlpha = 1.0;
});
// Ground
ctx.fillStyle = '#166534';
ctx.fillRect(0, HEIGHT - 40, WIDTH, 40);
ctx.fillStyle = '#86efac';
ctx.fillRect(0, HEIGHT - 40, WIDTH, 5);
// Moving Ground Pattern
ctx.fillStyle = '#14532d';
const groundOffset = (Date.now() / 5 * (P.speed/3.5)) % 40;
for(let i = -1; i < WIDTH/20; i++) {
ctx.beginPath();
ctx.moveTo(i * 40 - groundOffset, HEIGHT - 35);
ctx.lineTo(i * 40 + 20 - groundOffset, HEIGHT);
ctx.lineTo(i * 40 + 10 - groundOffset, HEIGHT);
ctx.lineTo(i * 40 - 10 - groundOffset, HEIGHT - 35);
ctx.fill();
}
// Particles
P.particles.forEach((p, i) => {
p.x += p.vx;
p.y += p.vy;
p.life -= 0.05;
ctx.fillStyle = p.color;
ctx.globalAlpha = Math.max(0, p.life);
ctx.beginPath();
ctx.arc(p.x, p.y, 3, 0, Math.PI * 2);
ctx.fill();
if (p.life <= 0) P.particles.splice(i, 1);
});
ctx.globalAlpha = 1.0;
// Bird
ctx.save();
ctx.translate(100, P.birdY);
ctx.rotate(P.rotation);
// Boost Aura
if (P.invincibleTimer > 0) {
ctx.shadowBlur = 20;
ctx.shadowColor = '#facc15';
ctx.fillStyle = 'rgba(250, 204, 21, 0.5)';
ctx.beginPath();
ctx.arc(0, 0, 30, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
}
// Tail
ctx.fillStyle = '#b91c1c';
ctx.beginPath();
ctx.ellipse(-15, 0, 15, 8, 0, 0, Math.PI*2);
ctx.fill();
// Body
const bodyGrad = ctx.createRadialGradient(-5, -5, 2, 0, 0, 15);
bodyGrad.addColorStop(0, '#0ea5e9');
bodyGrad.addColorStop(1, '#1e3a8a');
ctx.fillStyle = bodyGrad;
ctx.beginPath();
ctx.ellipse(0, 0, 18, 14, 0, 0, Math.PI*2);
ctx.fill();
// Wing
ctx.fillStyle = '#0f766e';
ctx.beginPath();
ctx.ellipse(2, 4, 10, 6, 0.2, 0, Math.PI*2);
ctx.fill();
// Eye
ctx.fillStyle = 'white';
ctx.beginPath();
ctx.arc(10, -6, 5, 0, Math.PI*2);
ctx.fill();
ctx.fillStyle = 'black';
ctx.beginPath();
ctx.arc(12, -6, 2, 0, Math.PI*2);
ctx.fill();
// Beak
ctx.fillStyle = '#fbbf24';
ctx.beginPath();
ctx.moveTo(14, -4);
ctx.lineTo(24, 0);
ctx.lineTo(14, 4);
ctx.fill();
ctx.restore();
// Flash Effect
if (P.flashOpacity > 0) {
ctx.fillStyle = `rgba(255, 255, 255, ${P.flashOpacity})`;
ctx.fillRect(0, 0, WIDTH, HEIGHT);
}
requestRef.current = requestAnimationFrame(loop);
};
return (
<div className="fixed inset-0 z-50 bg-[#020617] flex flex-col items-center justify-center select-none overflow-hidden font-sans" onMouseDown={flap} onTouchStart={flap} onKeyDown={(e) => { if (e.code === 'Space') flap(); }} tabIndex={0}>
<div className="absolute top-6 w-full max-w-5xl px-6 z-50 flex justify-between items-start pointer-events-none">
<button onClick={(e) => { e.stopPropagation(); onExit(); }} className="pointer-events-auto flex items-center gap-2 px-6 py-3 bg-white/10 hover:bg-white/20 text-white rounded-2xl backdrop-blur-md border border-white/10 transition-all font-black text-xs tracking-widest shadow-2xl">
<ArrowLeft size={18} /> EXIT
</button>
<div className="flex gap-2">
<button onClick={(e) => { e.stopPropagation(); setAudioEnabled(!audioEnabled); }} className="pointer-events-auto p-3 bg-white/10 hover:bg-white/20 text-white rounded-2xl backdrop-blur-md border border-white/10 transition-all shadow-2xl">
{audioEnabled ? <Volume2 size={20}/> : <VolumeX size={20}/>}
</button>
</div>
</div>
{/* Game Container */}
<div className="relative rounded-[3rem] overflow-hidden shadow-[0_0_120px_rgba(14,165,233,0.3)] border-[14px] border-gray-900 w-full max-w-xl aspect-[9/16] md:aspect-[3/4] bg-sky-300">
<canvas ref={canvasRef} width={600} height={800} className="w-full h-full block touch-none cursor-pointer"/>
{/* HUD - Score & Level */}
{gameState !== 'READY' && (
<div className="absolute top-10 w-full text-center pointer-events-none flex flex-col items-center gap-1">
<span className="text-6xl font-black text-white drop-shadow-[0_4px_4px_rgba(0,0,0,0.4)] stroke-black leading-none">{score}</span>
<span className="text-xs font-black text-white/80 uppercase tracking-widest bg-black/20 px-3 py-1 rounded-full backdrop-blur-sm">Level {level}</span>
</div>
)}
{/* Boost Indicator */}
{gameState === 'PLAYING' && boostUnlocked && (
<div className="absolute bottom-10 right-6 pointer-events-none flex flex-col items-center gap-2">
<div
className={`w-16 h-16 rounded-full flex items-center justify-center border-4 transition-all duration-300 relative overflow-hidden ${isBoosting ? 'bg-yellow-400 border-yellow-200 scale-110 shadow-[0_0_30px_#facc15]' : boostCharge >= PIPES_FOR_BOOST ? 'bg-yellow-500 border-white animate-pulse' : 'bg-gray-800 border-gray-600'}`}
>
{/* Charge Fill */}
{!isBoosting && (
<div
className="absolute bottom-0 left-0 w-full bg-yellow-500/50 transition-all duration-500 ease-out"
style={{ height: `${(boostCharge / PIPES_FOR_BOOST) * 100}%` }}
/>
)}
<div className="relative z-10">
{isBoosting ? <Shield size={32} className="text-white"/> : <Zap size={32} className={boostCharge >= PIPES_FOR_BOOST ? "text-white" : "text-gray-400"}/>}
</div>
</div>
<span className="text-[10px] font-black text-white uppercase tracking-widest bg-black/40 px-2 py-1 rounded">
{boostCharge >= PIPES_FOR_BOOST ? 'Press E' : `${boostCharge}/${PIPES_FOR_BOOST}`}
</span>
</div>
)}
{/* Ready Screen */}
{gameState === 'READY' && (
<div className="absolute inset-0 flex flex-col items-center justify-center text-white pointer-events-none">
<div className="bg-black/40 backdrop-blur-md p-8 rounded-3xl border border-white/20 text-center animate-in zoom-in duration-300">
<h2 className="text-5xl font-black italic tracking-tighter uppercase mb-2 text-transparent bg-clip-text bg-gradient-to-b from-white to-blue-200 drop-shadow-sm">Get Ready</h2>
<div className="w-24 h-24 mx-auto my-6 bg-white/10 rounded-full flex items-center justify-center animate-bounce border-2 border-white/30">
<Play size={40} className="ml-1 fill-white"/>
</div>
<div className="space-y-2">
<p className="font-bold text-lg uppercase tracking-widest opacity-90">Tap / Space to Fly</p>
<p className="text-xs font-medium text-yellow-300 uppercase tracking-wide opacity-80">Reach Lvl 4 to Unlock Boost [E]</p>
</div>
</div>
</div>
)}
{/* Game Over Screen */}
{gameState === 'GAMEOVER' && (
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm flex flex-col items-center justify-center text-white p-6 text-center animate-in zoom-in duration-300 z-20">
<h2 className="text-6xl font-black text-white drop-shadow-[0_10px_0_#0f172a] tracking-tighter mb-8 uppercase italic">Game Over</h2>
<div className="bg-white text-gray-900 w-full max-w-sm rounded-[2.5rem] p-8 shadow-2xl mb-8 transform rotate-1">
<div className="flex justify-between items-center mb-4 border-b-2 border-gray-100 pb-4">
<div className="text-left">
<p className="text-xs font-black text-orange-500 uppercase tracking-widest">Score</p>
<p className="text-4xl font-black">{score}</p>
</div>
<div className="text-right">
<p className="text-xs font-black text-blue-500 uppercase tracking-widest">Level</p>
<p className="text-4xl font-black">{level}</p>
</div>
</div>
{score >= 10 && (
<div className="flex justify-center py-4">
<Trophy size={64} className="text-yellow-400 drop-shadow-md fill-yellow-200 animate-bounce" />
</div>
)}
<p className="text-gray-400 font-bold text-sm italic mt-2">Best Score: {highScore}</p>
</div>
<Button onClick={(e) => { e.stopPropagation(); resetGame(); }} className="bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 font-black px-12 py-6 text-xl rounded-[2rem] shadow-xl flex items-center gap-3 active:scale-95 transition-transform pointer-events-auto">
<RefreshCw size={24} /> PLAY AGAIN
</Button>
</div>
)}
</div>
</div>
);
};