first commit
This commit is contained in:
391
src/components/games/CoinCatcher.tsx
Normal file
391
src/components/games/CoinCatcher.tsx
Normal file
@@ -0,0 +1,391 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useUser } from "@/lib/context";
|
||||
import { Play, RotateCcw, Coins, ShieldAlert, Award, Heart } from "lucide-react";
|
||||
|
||||
interface Particle {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
color: string;
|
||||
alpha: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface GameObject {
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
speed: number;
|
||||
type: "coin" | "obstacle";
|
||||
}
|
||||
|
||||
export default function CoinCatcher() {
|
||||
const { addLyts } = useUser();
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
// Game states
|
||||
const [gameState, setGameState] = useState<"idle" | "playing" | "gameover">("idle");
|
||||
const [score, setScore] = useState(0);
|
||||
const [lives, setLives] = useState(3);
|
||||
const [claimed, setClaimed] = useState(false);
|
||||
|
||||
// References for mutable game loop variables
|
||||
const requestRef = useRef<number>(0);
|
||||
const objectsRef = useRef<GameObject[]>([]);
|
||||
const particlesRef = useRef<Particle[]>([]);
|
||||
const playerXRef = useRef<number>(200);
|
||||
const livesRef = useRef<number>(3);
|
||||
const scoreRef = useRef<number>(0);
|
||||
const spawnTimerRef = useRef<number>(0);
|
||||
|
||||
// Start the game
|
||||
const startGame = () => {
|
||||
setGameState("playing");
|
||||
setScore(0);
|
||||
setLives(3);
|
||||
setClaimed(false);
|
||||
livesRef.current = 3;
|
||||
scoreRef.current = 0;
|
||||
objectsRef.current = [];
|
||||
particlesRef.current = [];
|
||||
playerXRef.current = 200;
|
||||
};
|
||||
|
||||
// Claim score as LYTs
|
||||
const claimEarnings = () => {
|
||||
if (score > 0 && !claimed) {
|
||||
addLyts(score);
|
||||
setClaimed(true);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
// Track mouse coordinates to control player position
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const relativeX = e.clientX - rect.left;
|
||||
// Clamp player position within canvas bounds
|
||||
playerXRef.current = Math.max(30, Math.min(canvas.width - 30, relativeX));
|
||||
};
|
||||
|
||||
// Track touch events for mobile support
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
if (e.touches.length === 0) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const relativeX = e.touches[0].clientX - rect.left;
|
||||
playerXRef.current = Math.max(30, Math.min(canvas.width - 30, relativeX));
|
||||
};
|
||||
|
||||
canvas.addEventListener("mousemove", handleMouseMove);
|
||||
canvas.addEventListener("touchmove", handleTouchMove);
|
||||
|
||||
// Particle emitter for explosion effects
|
||||
const createExplosion = (x: number, y: number, color: string) => {
|
||||
for (let i = 0; i < 15; i++) {
|
||||
particlesRef.current.push({
|
||||
x,
|
||||
y,
|
||||
vx: (Math.random() - 0.5) * 6,
|
||||
vy: (Math.random() - 0.5) * 6 - 2,
|
||||
color,
|
||||
alpha: 1,
|
||||
size: Math.random() * 3 + 2,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Main physics and rendering game loop
|
||||
const gameLoop = () => {
|
||||
if (gameState !== "playing") return;
|
||||
|
||||
// Clear Canvas
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// 1. Draw Catcher Bucket (Player)
|
||||
const pX = playerXRef.current;
|
||||
const pY = canvas.height - 35;
|
||||
|
||||
// Outer glow for player
|
||||
ctx.shadowBlur = 15;
|
||||
ctx.shadowColor = "#ffc72c";
|
||||
|
||||
ctx.fillStyle = "#ffc72c";
|
||||
ctx.beginPath();
|
||||
// Draw a sleek curved basket shape
|
||||
ctx.roundRect(pX - 35, pY, 70, 15, [4, 4, 10, 10]);
|
||||
ctx.fill();
|
||||
|
||||
// Inside mesh glow
|
||||
ctx.shadowBlur = 5;
|
||||
ctx.shadowColor = "#ff2e88";
|
||||
ctx.fillStyle = "#ff2e88";
|
||||
ctx.fillRect(pX - 25, pY + 4, 50, 2);
|
||||
ctx.shadowBlur = 0; // Reset shadow
|
||||
|
||||
// 2. Spawn Items
|
||||
spawnTimerRef.current += 1;
|
||||
if (spawnTimerRef.current > 40) {
|
||||
spawnTimerRef.current = 0;
|
||||
const type = Math.random() > 0.35 ? "coin" : "obstacle";
|
||||
objectsRef.current.push({
|
||||
x: Math.random() * (canvas.width - 40) + 20,
|
||||
y: -20,
|
||||
size: type === "coin" ? 12 : 14,
|
||||
speed: Math.random() * 2.5 + 3 + scoreRef.current * 0.05, // Speed increases as score goes up
|
||||
type,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Update and Draw Falling Items
|
||||
const activeObjects: GameObject[] = [];
|
||||
objectsRef.current.forEach((obj) => {
|
||||
obj.y += obj.speed;
|
||||
|
||||
// Collision Check (AABB / Circle collision against basket)
|
||||
const hitPlayer =
|
||||
obj.y + obj.size >= pY &&
|
||||
obj.y - obj.size <= pY + 15 &&
|
||||
obj.x >= pX - 45 &&
|
||||
obj.x <= pX + 45;
|
||||
|
||||
if (hitPlayer) {
|
||||
if (obj.type === "coin") {
|
||||
scoreRef.current += 1;
|
||||
setScore(scoreRef.current);
|
||||
createExplosion(obj.x, obj.y, "#ffc72c");
|
||||
} else {
|
||||
livesRef.current -= 1;
|
||||
setLives(livesRef.current);
|
||||
createExplosion(obj.x, obj.y, "#ff2e88");
|
||||
|
||||
// Screen shake effect on bomb hit
|
||||
ctx.translate((Math.random() - 0.5) * 15, (Math.random() - 0.5) * 15);
|
||||
|
||||
if (livesRef.current <= 0) {
|
||||
setGameState("gameover");
|
||||
}
|
||||
}
|
||||
} else if (obj.y < canvas.height + 20) {
|
||||
activeObjects.push(obj);
|
||||
}
|
||||
});
|
||||
objectsRef.current = activeObjects;
|
||||
|
||||
// Draw active falling objects
|
||||
objectsRef.current.forEach((obj) => {
|
||||
ctx.shadowBlur = 12;
|
||||
if (obj.type === "coin") {
|
||||
// Yellow LYT Coin
|
||||
ctx.shadowColor = "#ffc72c";
|
||||
ctx.fillStyle = "#ffc72c";
|
||||
ctx.beginPath();
|
||||
ctx.arc(obj.x, obj.y, obj.size, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Outer yellow shine ring
|
||||
ctx.strokeStyle = "#ffffff";
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
|
||||
// Core details
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.font = "bold 9px Arial";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText("L", obj.x, obj.y);
|
||||
} else {
|
||||
// Purple Obstacle Spike
|
||||
ctx.shadowColor = "#8b3dff";
|
||||
ctx.fillStyle = "#8b3dff";
|
||||
|
||||
ctx.beginPath();
|
||||
// Draw simple triangle obstacle
|
||||
ctx.moveTo(obj.x, obj.y - obj.size);
|
||||
ctx.lineTo(obj.x + obj.size, obj.y + obj.size);
|
||||
ctx.lineTo(obj.x - obj.size, obj.y + obj.size);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
// Spiky border
|
||||
ctx.strokeStyle = "#ff2e88";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
}
|
||||
});
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// 4. Update and Draw Particle Explosions
|
||||
particlesRef.current.forEach((p, idx) => {
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
p.alpha -= 0.025;
|
||||
|
||||
ctx.fillStyle = p.color;
|
||||
ctx.globalAlpha = Math.max(0, p.alpha);
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
ctx.globalAlpha = 1.0;
|
||||
particlesRef.current = particlesRef.current.filter((p) => p.alpha > 0);
|
||||
|
||||
// Loop
|
||||
requestRef.current = requestAnimationFrame(gameLoop);
|
||||
};
|
||||
|
||||
if (gameState === "playing") {
|
||||
requestRef.current = requestAnimationFrame(gameLoop);
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(requestRef.current);
|
||||
canvas.removeEventListener("mousemove", handleMouseMove);
|
||||
canvas.removeEventListener("touchmove", handleTouchMove);
|
||||
};
|
||||
}, [gameState]);
|
||||
|
||||
return (
|
||||
<section id="play" className="py-24 bg-[#060606] relative overflow-hidden">
|
||||
{/* Background glow */}
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] rounded-full bg-accent-pink/5 blur-[120px] pointer-events-none" />
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
||||
|
||||
{/* Header */}
|
||||
<div className="text-center max-w-3xl mx-auto mb-16 space-y-4">
|
||||
<span className="text-accent-lime font-extrabold text-xs tracking-widest uppercase">
|
||||
Play & Earn
|
||||
</span>
|
||||
<h2 className="text-3xl sm:text-5xl font-extrabold font-sora text-white leading-tight">
|
||||
The <span className="text-glow-yellow text-primary">LYT Catcher</span> Mini-Game
|
||||
</h2>
|
||||
<p className="text-white/60 text-base sm:text-lg">
|
||||
Use your mouse or touch screen to catch the falling yellow LYT coins. Avoid the purple hazard spikes! Points are credited straight to your wallet.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Game Box Container */}
|
||||
<div className="max-w-xl mx-auto">
|
||||
<div className="relative rounded-3xl overflow-hidden bg-[#0a0a0a] border border-white/5 shadow-[0_20px_50px_rgba(0,0,0,0.8)] aspect-[4/5] sm:aspect-[4/5] w-full">
|
||||
|
||||
{/* Top Game UI Bar (While Playing) */}
|
||||
{gameState === "playing" && (
|
||||
<div className="absolute top-4 left-4 right-4 flex justify-between items-center z-10">
|
||||
{/* Score */}
|
||||
<div className="flex items-center space-x-1.5 px-3 py-1.5 rounded-full bg-black/50 border border-white/10 text-primary font-bold text-sm">
|
||||
<Coins className="w-4 h-4 text-primary animate-pulse" />
|
||||
<span>Score: {score}</span>
|
||||
</div>
|
||||
|
||||
{/* Lives */}
|
||||
<div className="flex items-center space-x-1 px-3 py-1.5 rounded-full bg-black/50 border border-white/10 text-red-500 font-semibold text-sm">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Heart
|
||||
key={i}
|
||||
className={`w-4 h-4 fill-current ${
|
||||
i < lives ? "text-red-500" : "text-white/20"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Canvas */}
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={480}
|
||||
height={600}
|
||||
className="w-full h-full block bg-radial-gradient"
|
||||
/>
|
||||
|
||||
{/* Screens overlays */}
|
||||
|
||||
{/* 1. Idle Overlay */}
|
||||
{gameState === "idle" && (
|
||||
<div className="absolute inset-0 bg-dark/90 flex flex-col items-center justify-center p-6 text-center space-y-6">
|
||||
<div className="w-16 h-16 rounded-3xl bg-primary/10 border border-primary/20 flex items-center justify-center text-primary shadow-[0_0_20px_rgba(255,199,44,0.1)]">
|
||||
<Coins className="w-8 h-8 text-primary animate-bounce" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-2xl font-bold font-sora text-white">Ready to Catch?</h3>
|
||||
<p className="text-white/60 text-xs max-w-xs leading-relaxed">
|
||||
Move your mouse or finger horizontally to control the catcher basket. Grab coins, avoid spikes!
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={startGame}
|
||||
className="px-8 py-3.5 rounded-2xl bg-primary text-dark font-extrabold text-sm tracking-wider flex items-center justify-center gap-2 shadow-[0_0_25px_rgba(255,199,44,0.3)] hover:scale-105 transition-transform cursor-pointer"
|
||||
>
|
||||
<Play className="w-4.5 h-4.5 fill-current" />
|
||||
<span>Start Game</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 2. Gameover Overlay */}
|
||||
{gameState === "gameover" && (
|
||||
<div className="absolute inset-0 bg-dark/95 flex flex-col items-center justify-center p-6 text-center space-y-8 animate-in fade-in duration-300">
|
||||
<div className="w-16 h-16 rounded-3xl bg-red-500/10 border border-red-500/20 flex items-center justify-center text-red-500">
|
||||
<ShieldAlert className="w-8 h-8" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-3xl font-extrabold font-sora text-white">Game Over</h3>
|
||||
<p className="text-white/60 text-sm">
|
||||
You caught <span className="text-primary font-bold text-lg">{score}</span> LYTs!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Claim details */}
|
||||
<div className="w-full max-w-xs space-y-3">
|
||||
<button
|
||||
onClick={claimEarnings}
|
||||
disabled={score === 0 || claimed}
|
||||
className={`w-full py-4 rounded-2xl font-extrabold text-sm tracking-wide flex items-center justify-center gap-2 cursor-pointer transition-all ${
|
||||
claimed
|
||||
? "bg-accent-lime text-dark shadow-[0_0_20px_rgba(200,255,55,0.3)]"
|
||||
: score === 0
|
||||
? "bg-white/5 text-white/20 border border-white/5 cursor-not-allowed"
|
||||
: "bg-primary text-dark shadow-[0_0_25px_rgba(255,199,44,0.3)]"
|
||||
}`}
|
||||
>
|
||||
{claimed ? (
|
||||
<>
|
||||
<Award className="w-4.5 h-4.5" />
|
||||
<span>+{score} LYTs Claimed!</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Coins className="w-4.5 h-4.5" />
|
||||
<span>Claim Reward (+{score} LYTs)</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={startGame}
|
||||
className="w-full py-3.5 rounded-2xl bg-white/5 hover:bg-white/10 border border-white/10 text-white font-bold text-sm tracking-wide cursor-pointer flex items-center justify-center gap-2 transition-all"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
<span>Play Again</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user