diff --git a/src/features/home/HomePage.tsx b/src/features/home/HomePage.tsx index 8adf45f..3935154 100644 --- a/src/features/home/HomePage.tsx +++ b/src/features/home/HomePage.tsx @@ -44,7 +44,7 @@ export function HomePage() { -
+
}> diff --git a/src/features/snake-game/SnakeGame.tsx b/src/features/snake-game/SnakeGame.tsx index d8c947a..71e8f75 100644 --- a/src/features/snake-game/SnakeGame.tsx +++ b/src/features/snake-game/SnakeGame.tsx @@ -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 (
-
+
-
- +
+ + {status === 'countdown' && } + + {status === 'paused' && ( + + )} + {status === 'idle' && ( -
- + <> + {highScore > 0 && ( +
+ +
+ )} +
+ +
+ + )} + + {status === 'playing' && combo > 1 && ( +
+
)}
@@ -88,7 +134,7 @@ export function SnakeGame({ className = '' }: SnakeGameProps) { diff --git a/src/features/snake-game/components/ComboBadge.tsx b/src/features/snake-game/components/ComboBadge.tsx new file mode 100644 index 0000000..7f47874 --- /dev/null +++ b/src/features/snake-game/components/ComboBadge.tsx @@ -0,0 +1,15 @@ +interface ComboBadgeProps { + combo: number; +} + +export function ComboBadge({ combo }: ComboBadgeProps) { + const multiplier = Math.min(combo, 5); + return ( +
+ combo x{multiplier} +
+ ); +} diff --git a/src/features/snake-game/components/Countdown.tsx b/src/features/snake-game/components/Countdown.tsx new file mode 100644 index 0000000..d690b6a --- /dev/null +++ b/src/features/snake-game/components/Countdown.tsx @@ -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(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 ( +
+
+ {value === 'go' ? 'GO!' : value} +
+
+ ); +} diff --git a/src/features/snake-game/components/GameCanvas.tsx b/src/features/snake-game/components/GameCanvas.tsx index 8835a0c..953b707 100644 --- a/src/features/snake-game/components/GameCanvas.tsx +++ b/src/features/snake-game/components/GameCanvas.tsx @@ -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(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(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; @@ -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)) { @@ -75,10 +86,8 @@ export const GameCanvas = memo(function GameCanvas({ food, gridSize }: GameCanva }, [gridSize]); return ( -
-
+
+
{foodPos && ( - - - - + + + + )} + + {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 ( + + ); + })}
diff --git a/src/features/snake-game/components/GameOverlay.tsx b/src/features/snake-game/components/GameOverlay.tsx index b123384..0cc0bc8 100644 --- a/src/features/snake-game/components/GameOverlay.tsx +++ b/src/features/snake-game/components/GameOverlay.tsx @@ -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 (
{entry.name} + + {dateLabel} + {DIFFICULTY_LABELS[entry.difficulty]} @@ -128,7 +136,7 @@ export const GameOverlay = memo(function GameOverlay({ diff --git a/src/features/snake-game/components/HighScoreBadge.tsx b/src/features/snake-game/components/HighScoreBadge.tsx new file mode 100644 index 0000000..0c0d0ad --- /dev/null +++ b/src/features/snake-game/components/HighScoreBadge.tsx @@ -0,0 +1,11 @@ +interface HighScoreBadgeProps { + highScore: number; +} + +export function HighScoreBadge({ highScore }: HighScoreBadgeProps) { + return ( +
+ best: {highScore} +
+ ); +} diff --git a/src/features/snake-game/components/PauseOverlay.tsx b/src/features/snake-game/components/PauseOverlay.tsx new file mode 100644 index 0000000..f881bcc --- /dev/null +++ b/src/features/snake-game/components/PauseOverlay.tsx @@ -0,0 +1,35 @@ +import { memo } from 'react'; + +interface PauseOverlayProps { + onResume: () => void; + onQuit: () => void; +} + +export const PauseOverlay = memo(function PauseOverlay({ onResume, onQuit }: PauseOverlayProps) { + return ( +
+
+

+ pausado +

+

+ // pressione espaco para continuar +

+
+ + +
+
+
+ ); +}); diff --git a/src/features/snake-game/components/ScoreDisplay.tsx b/src/features/snake-game/components/ScoreDisplay.tsx index 739b997..ffa491d 100644 --- a/src/features/snake-game/components/ScoreDisplay.tsx +++ b/src/features/snake-game/components/ScoreDisplay.tsx @@ -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 ( diff --git a/src/features/snake-game/constants/index.ts b/src/features/snake-game/constants/index.ts index bbc9c36..fe913b6 100644 --- a/src/features/snake-game/constants/index.ts +++ b/src/features/snake-game/constants/index.ts @@ -1,10 +1,13 @@ -import type { Direction, Position, Difficulty } from '../types'; +import type { Direction, Position, Difficulty, FoodType } from '../types'; export const GRID_SIZE = 15; export const FOOD_TO_WIN_CASUAL = 15; export const GAME_START_DELAY = 500; export const SVG_CANVAS_SIZE = 400; export const MAX_LEADERBOARD_ENTRIES = 10; +export const COUNTDOWN_SECONDS = 3; +export const COMBO_WINDOW_MS = 3000; +export const PARTICLE_LIFETIME_MS = 600; export const DIFFICULTY_SPEEDS: Record = { easy: 200, @@ -13,9 +16,15 @@ export const DIFFICULTY_SPEEDS: Record = { }; export const DIFFICULTY_LABELS: Record = { - easy: 'facil', - normal: 'normal', - hard: 'dificil', + easy: 'junior', + normal: 'pleno', + hard: 'senior', +}; + +export const FOOD_TYPES: Record = { + function: { points: 1, color: '#46ECD5', label: 'function()', weight: 70 }, + class: { points: 2, color: '#ffb86a', label: 'class{}', weight: 22 }, + async: { points: 3, color: '#b14eff', label: 'async()', weight: 8 }, }; export const MODE_LABELS = { diff --git a/src/features/snake-game/hooks/useSnakeGame.ts b/src/features/snake-game/hooks/useSnakeGame.ts index 2f832cd..29679cf 100644 --- a/src/features/snake-game/hooks/useSnakeGame.ts +++ b/src/features/snake-game/hooks/useSnakeGame.ts @@ -6,12 +6,17 @@ import type { Direction } from '../types'; export function useSnakeGame() { const status = useGameStore((s) => s.status); const score = useGameStore((s) => s.score); + const highScore = useGameStore((s) => s.highScore); const food = useGameStore((s) => s.food); const gridSize = useGameStore((s) => s.gridSize); const difficulty = useGameStore((s) => s.difficulty); const mode = useGameStore((s) => s.mode); const leaderboard = useGameStore((s) => s.leaderboard); + const combo = useGameStore((s) => s.combo); + const particles = useGameStore((s) => s.particles); + const shakeKey = useGameStore((s) => s.shakeKey); const startGame = useGameStore((s) => s.startGame); + const beginCountdown = useGameStore((s) => s.beginCountdown); const pauseGame = useGameStore((s) => s.pauseGame); const resumeGame = useGameStore((s) => s.resumeGame); const resetGame = useGameStore((s) => s.resetGame); @@ -28,9 +33,11 @@ export function useSnakeGame() { const key = event.key.toLowerCase(); if (key === ' ' || key === 'spacebar') { - event.preventDefault(); - if (statusRef.current === 'playing') pauseGame(); - else if (statusRef.current === 'paused') resumeGame(); + if (statusRef.current === 'playing' || statusRef.current === 'paused') { + event.preventDefault(); + if (statusRef.current === 'playing') pauseGame(); + else resumeGame(); + } return; } @@ -76,14 +83,19 @@ export function useSnakeGame() { return { status, score, + highScore, food, gridSize, difficulty, mode, leaderboard, + combo, + particles, + shakeKey, remainingFood, actions: { startGame, + beginCountdown, pauseGame, resumeGame, resetGame, diff --git a/src/features/snake-game/hooks/useSwipe.ts b/src/features/snake-game/hooks/useSwipe.ts new file mode 100644 index 0000000..4e51baa --- /dev/null +++ b/src/features/snake-game/hooks/useSwipe.ts @@ -0,0 +1,44 @@ +import { useRef } from 'react'; +import type { Direction } from '../types'; + +interface UseSwipeOptions { + onSwipe: (direction: Direction) => void; + enabled: boolean; + threshold?: number; +} + +export function useSwipe({ onSwipe, enabled, threshold = 30 }: UseSwipeOptions) { + const startRef = useRef<{ x: number; y: number } | null>(null); + + const onTouchStart = (e: React.TouchEvent) => { + if (!enabled) return; + const t = e.touches[0]; + if (!t) return; + startRef.current = { x: t.clientX, y: t.clientY }; + }; + + const onTouchEnd = (e: React.TouchEvent) => { + if (!enabled || !startRef.current) return; + const t = e.changedTouches[0]; + if (!t) return; + + const dx = t.clientX - startRef.current.x; + const dy = t.clientY - startRef.current.y; + const absDx = Math.abs(dx); + const absDy = Math.abs(dy); + + if (Math.max(absDx, absDy) < threshold) { + startRef.current = null; + return; + } + + if (absDx > absDy) { + onSwipe(dx > 0 ? 'RIGHT' : 'LEFT'); + } else { + onSwipe(dy > 0 ? 'DOWN' : 'UP'); + } + startRef.current = null; + }; + + return { onTouchStart, onTouchEnd }; +} diff --git a/src/features/snake-game/store/useGameStore.ts b/src/features/snake-game/store/useGameStore.ts index 49ef72e..d3717f3 100644 --- a/src/features/snake-game/store/useGameStore.ts +++ b/src/features/snake-game/store/useGameStore.ts @@ -1,5 +1,15 @@ import { create } from 'zustand'; -import type { GameStore, Position, Direction, Difficulty, GameMode, LeaderboardEntry } from '../types'; +import type { + GameStore, + Position, + Direction, + Difficulty, + GameMode, + LeaderboardEntry, + Food, + FoodType, + Particle, +} from '../types'; import { GRID_SIZE, FOOD_TO_WIN_CASUAL, @@ -7,9 +17,22 @@ import { INITIAL_DIRECTION, OPPOSITE_DIRECTIONS, MAX_LEADERBOARD_ENTRIES, + FOOD_TYPES, + COMBO_WINDOW_MS, + PARTICLE_LIFETIME_MS, } from '../constants'; -function generateRandomFood(snake: Position[]): Position | null { +function pickFoodType(): FoodType { + const totalWeight = Object.values(FOOD_TYPES).reduce((s, t) => s + t.weight, 0); + let r = Math.random() * totalWeight; + for (const [type, cfg] of Object.entries(FOOD_TYPES)) { + r -= cfg.weight; + if (r <= 0) return type as FoodType; + } + return 'function'; +} + +function generateRandomFood(snake: Position[]): Food | null { const totalCells = GRID_SIZE * GRID_SIZE; if (snake.length >= totalCells) return null; @@ -21,7 +44,8 @@ function generateRandomFood(snake: Position[]): Position | null { } } } - return availableCells[Math.floor(Math.random() * availableCells.length)] ?? null; + const pos = availableCells[Math.floor(Math.random() * availableCells.length)]; + return pos ? { ...pos, type: pickFoodType() } : null; } function getStoredHighScore(): number { @@ -63,6 +87,19 @@ function saveLeaderboard(entries: LeaderboardEntry[]) { } } +let particleIdSeq = 0; + +function spawnParticles(x: number, y: number, color: string, count = 8): Particle[] { + const now = performance.now(); + return Array.from({ length: count }, () => ({ + id: ++particleIdSeq, + x, + y, + color, + createdAt: now, + })); +} + export const useGameStore = create((set, get) => ({ status: 'idle', score: 0, @@ -78,6 +115,10 @@ export const useGameStore = create((set, get) => ({ difficulty: 'easy' as Difficulty, mode: 'casual' as GameMode, leaderboard: getStoredLeaderboard(), + combo: 0, + lastEatTime: 0, + particles: [], + shakeKey: 0, setDifficulty: (difficulty: Difficulty) => { const { status } = get(); @@ -91,17 +132,27 @@ export const useGameStore = create((set, get) => ({ set({ mode }); }, - startGame: () => { + beginCountdown: () => { set({ - status: 'playing', + status: 'countdown', score: 0, snake: INITIAL_SNAKE, prevSnake: INITIAL_SNAKE, - lastTickTime: performance.now(), + lastTickTime: 0, direction: INITIAL_DIRECTION, nextDirection: INITIAL_DIRECTION, directionQueue: [], food: generateRandomFood(INITIAL_SNAKE), + combo: 0, + lastEatTime: 0, + particles: [], + }); + }, + + startGame: () => { + set({ + status: 'playing', + lastTickTime: performance.now(), }); }, @@ -114,10 +165,10 @@ export const useGameStore = create((set, get) => ({ }, gameOver: () => { - const { score, highScore } = get(); + const { score, highScore, shakeKey } = get(); const newHighScore = Math.max(score, highScore); if (newHighScore > highScore) saveHighScore(newHighScore); - set({ status: 'game-over', highScore: newHighScore }); + set({ status: 'game-over', highScore: newHighScore, combo: 0, shakeKey: shakeKey + 1 }); }, resetGame: () => { @@ -131,6 +182,9 @@ export const useGameStore = create((set, get) => ({ nextDirection: INITIAL_DIRECTION, directionQueue: [], food: generateRandomFood(INITIAL_SNAKE), + combo: 0, + lastEatTime: 0, + particles: [], }); }, @@ -163,8 +217,12 @@ export const useGameStore = create((set, get) => ({ } }, + clearParticle: (id: number) => { + set({ particles: get().particles.filter((p) => p.id !== id) }); + }, + moveSnake: () => { - const { snake, direction, directionQueue, food, status, mode } = get(); + const { snake, direction, directionQueue, food, status, mode, combo, lastEatTime, particles, shakeKey } = get(); if (status !== 'playing') return; let currentDirection = direction; @@ -217,8 +275,13 @@ export const useGameStore = create((set, get) => ({ if (food && newHead.x === food.x && newHead.y === food.y) { const { score, highScore } = get(); - const newScore = score + 1; + const foodCfg = FOOD_TYPES[food.type]; + const withinCombo = tickTime - lastEatTime < COMBO_WINDOW_MS && lastEatTime > 0; + const newCombo = withinCombo ? combo + 1 : 1; + const comboMultiplier = Math.min(newCombo, 5); + const newScore = score + foodCfg.points * comboMultiplier; const newFood = generateRandomFood(newSnake); + const newParticles = [...particles, ...spawnParticles(food.x, food.y, foodCfg.color, 10)]; if (!newFood || (mode === 'casual' && newScore >= FOOD_TO_WIN_CASUAL)) { const newHighScore = Math.max(newScore, highScore); @@ -232,6 +295,10 @@ export const useGameStore = create((set, get) => ({ prevSnake: snake, lastTickTime: tickTime, food: newFood, + combo: newCombo, + lastEatTime: tickTime, + particles: newParticles, + shakeKey: shakeKey + 1, }); } else { set({ @@ -241,16 +308,29 @@ export const useGameStore = create((set, get) => ({ prevSnake: snake, lastTickTime: tickTime, food: newFood, + combo: newCombo, + lastEatTime: tickTime, + particles: newParticles, + shakeKey: shakeKey + 1, }); } } else { newSnake.pop(); + // break combo if window expired + const comboExpired = lastEatTime > 0 && tickTime - lastEatTime > COMBO_WINDOW_MS; set({ ...dirUpdate, snake: newSnake, prevSnake: snake, lastTickTime: tickTime, + combo: comboExpired ? 0 : combo, }); } + + // cleanup old particles + const livingParticles = get().particles.filter((p) => tickTime - p.createdAt < PARTICLE_LIFETIME_MS); + if (livingParticles.length !== get().particles.length) { + set({ particles: livingParticles }); + } }, })); diff --git a/src/features/snake-game/types/index.ts b/src/features/snake-game/types/index.ts index fcb2a08..ce3348a 100644 --- a/src/features/snake-game/types/index.ts +++ b/src/features/snake-game/types/index.ts @@ -1,4 +1,10 @@ -export type GameStatus = 'idle' | 'playing' | 'paused' | 'game-over' | 'victory'; +export type GameStatus = + | 'idle' + | 'countdown' + | 'playing' + | 'paused' + | 'game-over' + | 'victory'; export type Position = { x: number; @@ -11,6 +17,20 @@ export type Difficulty = 'easy' | 'normal' | 'hard'; export type GameMode = 'casual' | 'competitive'; +export type FoodType = 'function' | 'class' | 'async'; + +export interface Food extends Position { + type: FoodType; +} + +export interface Particle { + id: number; + x: number; + y: number; + color: string; + createdAt: number; +} + export interface LeaderboardEntry { name: string; score: number; @@ -28,15 +48,20 @@ export interface GameState { direction: Direction; nextDirection: Direction; directionQueue: Direction[]; - food: Position | null; + food: Food | null; gridSize: number; difficulty: Difficulty; mode: GameMode; leaderboard: LeaderboardEntry[]; + combo: number; + lastEatTime: number; + particles: Particle[]; + shakeKey: number; } export interface GameActions { startGame: () => void; + beginCountdown: () => void; pauseGame: () => void; resumeGame: () => void; gameOver: () => void; @@ -46,6 +71,7 @@ export interface GameActions { setDifficulty: (difficulty: Difficulty) => void; setMode: (mode: GameMode) => void; saveToLeaderboard: (name: string) => void; + clearParticle: (id: number) => void; } export type GameStore = GameState & GameActions; diff --git a/src/styles/theme.css b/src/styles/theme.css index 9b833a1..c7b5f6e 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -54,6 +54,67 @@ animation: tab-fade-in 0.2s ease-out; } +@keyframes snake-shake { + 0%, 100% { transform: translate(0, 0); } + 20% { transform: translate(-2px, 1px); } + 40% { transform: translate(2px, -1px); } + 60% { transform: translate(-1px, 2px); } + 80% { transform: translate(1px, -2px); } +} + +.snake-shake { + animation: snake-shake 0.18s ease-in-out; +} + +@keyframes snake-food-pulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.15); } +} + +.snake-food-pulse { + animation: snake-food-pulse 1.2s ease-in-out infinite; + transform-box: fill-box; +} + +@keyframes snake-particle-burst { + 0% { + transform: translate(0, 0) scale(1); + opacity: 1; + } + 100% { + transform: translate(var(--px, 0), var(--py, 0)) scale(0.2); + opacity: 0; + } +} + +.snake-particle { + animation-name: snake-particle-burst; + animation-timing-function: ease-out; + animation-fill-mode: forwards; + transform-box: fill-box; + transform-origin: center; +} + +@keyframes combo-pop { + 0% { transform: scale(0.7); opacity: 0; } + 50% { transform: scale(1.15); } + 100% { transform: scale(1); opacity: 1; } +} + +.combo-pop { + animation: combo-pop 0.25s ease-out; +} + +@keyframes countdown-pop { + 0% { transform: scale(0.4); opacity: 0; } + 40% { transform: scale(1.1); opacity: 1; } + 100% { transform: scale(1); opacity: 1; } +} + +.countdown-pop { + animation: countdown-pop 0.3s ease-out; +} + @media (prefers-reduced-motion: reduce) { *, *::before, @@ -64,7 +125,10 @@ scroll-behavior: auto !important; } - .animate-tab-fade-in { + .animate-tab-fade-in, + .snake-shake, + .snake-food-pulse, + .snake-particle { animation: none; } }