392 lines
14 KiB
TypeScript
392 lines
14 KiB
TypeScript
"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>
|
|
);
|
|
}
|