}>
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;
}
}