Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/features/home/HomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export function HomePage() {
</div>
</div>

<div className="hidden lg:flex justify-end min-w-0">
<div className="flex justify-end min-w-0">
<Suspense fallback={<div className="w-full" />}>
<SnakeGame className="w-full" />
</Suspense>
Expand Down
78 changes: 62 additions & 16 deletions src/features/snake-game/SnakeGame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,47 @@ import { GameControls } from './components/GameControls';
import { ScoreDisplay } from './components/ScoreDisplay';
import { DecorativeBolt } from './components/DecorativeBolt';
import { GameTabs } from './components/GameTabs';
import { HighScoreBadge } from './components/HighScoreBadge';
import { ComboBadge } from './components/ComboBadge';
import { Countdown } from './components/Countdown';
import { PauseOverlay } from './components/PauseOverlay';
import { useSwipe } from './hooks/useSwipe';

interface SnakeGameProps {
className?: string;
}

export function SnakeGame({ className = '' }: SnakeGameProps) {
const { status, score, food, gridSize, difficulty, mode, leaderboard, actions } =
useSnakeGame();
const {
status,
score,
highScore,
food,
gridSize,
difficulty,
mode,
leaderboard,
combo,
particles,
shakeKey,
actions,
} = useSnakeGame();

const { setDirection, startGame } = actions;
const { setDirection, beginCountdown } = actions;

const handleDirectionClick = useCallback(
(direction: Direction) => setDirection(direction),
[setDirection],
);

const handleStartClick = useCallback(() => startGame(), [startGame]);
const handleStartClick = useCallback(() => beginCountdown(), [beginCountdown]);

const isPlaying = status === 'playing' || status === 'paused';
const swipeHandlers = useSwipe({
onSwipe: handleDirectionClick,
enabled: status === 'playing',
});

const isPlaying = status === 'playing' || status === 'paused' || status === 'countdown';

return (
<div
Expand All @@ -47,7 +69,7 @@ export function SnakeGame({ className = '' }: SnakeGameProps) {

<div className="absolute inset-[-1px] pointer-events-none rounded-[inherit] shadow-[inset_0px_2px_0px_0px_rgba(255,255,255,0.3)]" />

<div className="relative flex flex-col gap-3">
<div className="relative flex flex-col gap-3 w-full lg:w-auto">
<GameTabs
difficulty={difficulty}
mode={mode}
Expand All @@ -56,8 +78,13 @@ export function SnakeGame({ className = '' }: SnakeGameProps) {
onMode={actions.setMode}
/>

<div className="relative">
<GameCanvas food={food} gridSize={gridSize} />
<div className="relative" {...swipeHandlers}>
<GameCanvas
food={food}
gridSize={gridSize}
particles={particles}
shakeKey={shakeKey}
/>
<GameOverlay
status={status}
score={score}
Expand All @@ -67,14 +94,33 @@ export function SnakeGame({ className = '' }: SnakeGameProps) {
onSaveScore={actions.saveToLeaderboard}
/>

{status === 'countdown' && <Countdown onFinish={actions.startGame} />}

{status === 'paused' && (
<PauseOverlay onResume={actions.resumeGame} onQuit={actions.resetGame} />
)}

{status === 'idle' && (
<div className="absolute bottom-12 left-1/2 -translate-x-1/2">
<button
onClick={handleStartClick}
className="bg-[#ffb86a] hover:bg-[#ffb86a]/90 transition-colors px-6 py-2.5 rounded-lg font-['Fira_Code',sans-serif] font-[450] text-[#020618] text-sm"
>
iniciar
</button>
<>
{highScore > 0 && (
<div className="absolute top-3 right-3 sm:top-6 sm:right-6 pointer-events-none">
<HighScoreBadge highScore={highScore} />
</div>
)}
<div className="absolute bottom-8 sm:bottom-12 left-1/2 -translate-x-1/2">
<button
onClick={handleStartClick}
className="bg-[#ffb86a] hover:bg-[#ffb86a]/90 transition-colors px-6 py-2.5 rounded-lg font-['Fira_Code',sans-serif] font-[450] text-[#020618] text-sm focus-visible:outline-2 focus-visible:outline-[#f8fafc] focus-visible:outline-offset-2"
>
iniciar
</button>
</div>
</>
)}

{status === 'playing' && combo > 1 && (
<div className="absolute top-3 right-3 sm:top-6 sm:right-6 pointer-events-none">
<ComboBadge combo={combo} />
</div>
)}
</div>
Expand All @@ -88,7 +134,7 @@ export function SnakeGame({ className = '' }: SnakeGameProps) {

<button
onClick={actions.resetGame}
className="border border-[#f8fafc] hover:bg-[#f8fafc]/10 transition-colors px-3 py-2.5 rounded-lg font-['Fira_Code',sans-serif] text-[#f8fafc] text-sm w-full"
className="border border-[#f8fafc] hover:bg-[#f8fafc]/10 transition-colors px-3 py-2.5 rounded-lg font-['Fira_Code',sans-serif] text-[#f8fafc] text-sm w-full focus-visible:outline-2 focus-visible:outline-[#ffb86a] focus-visible:outline-offset-2"
>
pular
</button>
Expand Down
15 changes: 15 additions & 0 deletions src/features/snake-game/components/ComboBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
interface ComboBadgeProps {
combo: number;
}

export function ComboBadge({ combo }: ComboBadgeProps) {
const multiplier = Math.min(combo, 5);
return (
<div
key={combo}
className="font-['Fira_Code',sans-serif] text-[11px] text-[#b14eff] bg-[#020618]/80 border border-[#b14eff]/40 px-2 py-1 rounded combo-pop"
>
combo x{multiplier}
</div>
);
}
33 changes: 33 additions & 0 deletions src/features/snake-game/components/Countdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { useEffect, useState } from 'react';
import { COUNTDOWN_SECONDS } from '../constants';

interface CountdownProps {
onFinish: () => void;
}

export function Countdown({ onFinish }: CountdownProps) {
const [value, setValue] = useState<number | 'go'>(COUNTDOWN_SECONDS);

useEffect(() => {
if (value === 'go') {
const t = setTimeout(onFinish, 400);
return () => clearTimeout(t);
}
const t = setTimeout(() => {
setValue((v) => (typeof v === 'number' && v > 1 ? v - 1 : 'go'));
}, 700);
return () => clearTimeout(t);
}, [value, onFinish]);

return (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
<div
key={String(value)}
className="font-['Fira_Code',sans-serif] font-bold text-[#ffb86a] text-7xl countdown-pop"
style={{ textShadow: '0 0 20px rgba(255, 184, 106, 0.6)' }}
>
{value === 'go' ? 'GO!' : value}
</div>
</div>
);
}
61 changes: 47 additions & 14 deletions src/features/snake-game/components/GameCanvas.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,31 @@
import { memo, useRef, useEffect } from 'react';
import type { Position } from '../types';
import type { Position, Food, Particle } from '../types';
import { useGameStore } from '../store/useGameStore';
import { gridToSvg, getSnakeDimensions } from '../utils/grid';
import { DIFFICULTY_SPEEDS } from '../constants';
import { DIFFICULTY_SPEEDS, FOOD_TYPES, PARTICLE_LIFETIME_MS } from '../constants';

interface GameCanvasProps {
food: Position | null;
food: Food | null;
gridSize: number;
particles: Particle[];
shakeKey: number;
}

export const GameCanvas = memo(function GameCanvas({ food, gridSize }: GameCanvasProps) {
export const GameCanvas = memo(function GameCanvas({ food, gridSize, particles, shakeKey }: GameCanvasProps) {
const pathRef = useRef<SVGPathElement>(null);
const { snakeStrokeWidth } = getSnakeDimensions(gridSize);
const foodPos = food ? gridToSvg(food.x, food.y, gridSize) : null;
const foodColor = food ? FOOD_TYPES[food.type].color : '#46ECD5';
const containerRef = useRef<HTMLDivElement>(null);

// trigger shake animation on key change
useEffect(() => {
if (!containerRef.current || shakeKey === 0) return;
const el = containerRef.current;
el.classList.remove('snake-shake');
void el.offsetWidth;
el.classList.add('snake-shake');
}, [shakeKey]);

useEffect(() => {
let animId: number;
Expand Down Expand Up @@ -41,8 +54,6 @@ export const GameCanvas = memo(function GameCanvas({ food, gridSize }: GameCanva
return pos;
});

// Tail extension: add an extra point that retracts toward
// the current tail, always on the same grid axis
const prevTail = prevSnake[lastIdx];
const currTail = snake[lastIdx]!;
if (prevTail && (prevTail.x !== currTail.x || prevTail.y !== currTail.y)) {
Expand Down Expand Up @@ -75,10 +86,8 @@ export const GameCanvas = memo(function GameCanvas({ food, gridSize }: GameCanva
}, [gridSize]);

return (
<div className="bg-[#1d293d] rounded-lg shadow-[inset_1px_5px_11px_0px_rgba(2,18,27,0.71)] p-3 sm:p-6">
<div
className="relative bg-[#0a1628] rounded-sm overflow-hidden w-full max-w-[400px] aspect-square"
>
<div ref={containerRef} className="bg-[#1d293d] rounded-lg shadow-[inset_1px_5px_11px_0px_rgba(2,18,27,0.71)] p-3 sm:p-6 w-full">
<div className="relative bg-[#0a1628] rounded-sm overflow-hidden w-full max-w-[400px] aspect-square mx-auto">
<svg
className="absolute inset-0 w-full h-full"
viewBox="0 0 400 400"
Expand All @@ -94,12 +103,36 @@ export const GameCanvas = memo(function GameCanvas({ food, gridSize }: GameCanva
/>

{foodPos && (
<g>
<circle cx={foodPos.x} cy={foodPos.y} r="10.3456" fill="#46ECD5" opacity="0.1" />
<circle cx={foodPos.x} cy={foodPos.y} r="7.34558" fill="#46ECD5" opacity="0.2" />
<circle cx={foodPos.x} cy={foodPos.y} r="4" fill="#46ECD5" />
<g className="snake-food-pulse" style={{ transformOrigin: `${foodPos.x}px ${foodPos.y}px` }}>
<circle cx={foodPos.x} cy={foodPos.y} r="10.3456" fill={foodColor} opacity="0.1" />
<circle cx={foodPos.x} cy={foodPos.y} r="7.34558" fill={foodColor} opacity="0.2" />
<circle cx={foodPos.x} cy={foodPos.y} r="4" fill={foodColor} />
</g>
)}

{particles.map((p) => {
const pos = gridToSvg(p.x, p.y, gridSize);
const angle = (p.id * 137.5) % 360;
const rad = (angle * Math.PI) / 180;
const dist = 18;
const dx = Math.cos(rad) * dist;
const dy = Math.sin(rad) * dist;
return (
<circle
key={p.id}
className="snake-particle"
style={{
['--px' as string]: `${dx}px`,
['--py' as string]: `${dy}px`,
animationDuration: `${PARTICLE_LIFETIME_MS}ms`,
}}
cx={pos.x}
cy={pos.y}
r="3"
fill={p.color}
/>
);
})}
</svg>
</div>
</div>
Expand Down
10 changes: 9 additions & 1 deletion src/features/snake-game/components/GameOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,11 @@ export const GameOverlay = memo(function GameOverlay({
(e) => e.date === entry.date && e.name === entry.name,
);

const dateLabel = new Date(entry.date).toLocaleDateString('pt-BR', {
day: '2-digit',
month: '2-digit',
});

return (
<div
key={`${entry.name}-${entry.date}`}
Expand All @@ -115,6 +120,9 @@ export const GameOverlay = memo(function GameOverlay({
)}
</span>
<span className="flex-1 truncate">{entry.name}</span>
<span className="text-[#607088] shrink-0 hidden sm:inline">
{dateLabel}
</span>
<span className="text-[#607088] shrink-0">
{DIFFICULTY_LABELS[entry.difficulty]}
</span>
Expand All @@ -128,7 +136,7 @@ export const GameOverlay = memo(function GameOverlay({

<button
onClick={handleRestart}
className="font-['Fira_Code',sans-serif] text-[#90a1b9] hover:text-[#f8fafc] transition-colors underline text-sm"
className="font-['Fira_Code',sans-serif] text-[#90a1b9] hover:text-[#f8fafc] transition-colors underline text-sm focus-visible:outline-2 focus-visible:outline-[#ffb86a]"
>
{status === 'victory' ? 'jogar-novamente' : 'tentar-novamente'}
</button>
Expand Down
11 changes: 11 additions & 0 deletions src/features/snake-game/components/HighScoreBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
interface HighScoreBadgeProps {
highScore: number;
}

export function HighScoreBadge({ highScore }: HighScoreBadgeProps) {
return (
<div className="font-['Fira_Code',sans-serif] text-[11px] text-[#ffb86a] bg-[#020618]/80 border border-[#ffb86a]/40 px-2 py-1 rounded">
best: {highScore}
</div>
);
}
35 changes: 35 additions & 0 deletions src/features/snake-game/components/PauseOverlay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { memo } from 'react';

interface PauseOverlayProps {
onResume: () => void;
onQuit: () => void;
}

export const PauseOverlay = memo(function PauseOverlay({ onResume, onQuit }: PauseOverlayProps) {
return (
<div className="absolute inset-0 bg-[#0a1628]/90 backdrop-blur-sm rounded-lg flex items-center justify-center z-20">
<div className="text-center space-y-5 p-4">
<p className="font-['Fira_Code',sans-serif] text-[#ffb86a] text-2xl font-bold">
pausado
</p>
<p className="font-['Fira_Code',sans-serif] text-[#90a1b9] text-xs">
// pressione espaco para continuar
</p>
<div className="flex flex-col gap-2 items-center">
<button
onClick={onResume}
className="bg-[#43D9AD] hover:bg-[#43D9AD]/80 transition-colors px-6 py-2 rounded-lg font-['Fira_Code',sans-serif] text-[#020618] text-sm w-40 focus-visible:outline-2 focus-visible:outline-[#ffb86a] focus-visible:outline-offset-2"
>
continuar
</button>
<button
onClick={onQuit}
className="border border-[#90a1b9] hover:border-[#f8fafc] hover:text-[#f8fafc] transition-colors px-6 py-2 rounded-lg font-['Fira_Code',sans-serif] text-[#90a1b9] text-sm w-40 focus-visible:outline-2 focus-visible:outline-[#ffb86a] focus-visible:outline-offset-2"
>
sair
</button>
</div>
</div>
</div>
);
});
3 changes: 2 additions & 1 deletion src/features/snake-game/components/ScoreDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ export const ScoreDisplay = memo(function ScoreDisplay({ score, mode }: ScoreDis
const row = Math.floor(index / 5);
const cx = col * 24 + 10;
const cy = row * 24 + 10;
const o = index < score ? 1 : 0.3;
const filled = index < Math.min(score, FOOD_TO_WIN_CASUAL);
const o = filled ? 1 : 0.3;
return (
<g key={index} opacity={o}>
<circle cx={cx} cy={cy} r="10" fill="#46ECD5" opacity="0.1" />
Expand Down
Loading
Loading