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/snake-game/SnakeGame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export function SnakeGame({ className = '' }: SnakeGameProps) {
onDifficulty={actions.setDifficulty}
onMode={actions.setMode}
/>
{highScore > 0 && <HighScoreBadge highScore={highScore} />}
{mode === 'competitive' && highScore > 0 && <HighScoreBadge highScore={highScore} />}
</div>

<div className="relative" {...swipeHandlers}>
Expand Down
10 changes: 8 additions & 2 deletions src/features/snake-game/components/GameControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ export const GameControls = memo(function GameControls({ status, onDirection }:

<div className="flex flex-col gap-1.5 items-center mt-2">
<button
onClick={() => onDirection('UP')}
onPointerDown={(e) => {
e.preventDefault();
onDirection('UP');
}}
disabled={disabled}
aria-label="Cima"
className={buttonClass}
Expand All @@ -41,7 +44,10 @@ export const GameControls = memo(function GameControls({ status, onDirection }:
{ROW_BUTTONS.map(({ direction, rotation }) => (
<button
key={direction}
onClick={() => onDirection(direction)}
onPointerDown={(e) => {
e.preventDefault();
onDirection(direction);
}}
disabled={disabled}
aria-label={direction.toLowerCase()}
className={buttonClass}
Expand Down
8 changes: 5 additions & 3 deletions src/features/snake-game/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ 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 MAX_DIRECTION_QUEUE = 2;
export const FAST_FORWARD_THRESHOLD = 0.35;

export const DIFFICULTY_SPEEDS: Record<Difficulty, number> = {
easy: 200,
normal: 150,
hard: 100,
easy: 170,
normal: 140,
hard: 80,
};

export const DIFFICULTY_LABELS: Record<Difficulty, string> = {
Expand Down
49 changes: 38 additions & 11 deletions src/features/snake-game/hooks/useSnakeGame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,36 @@ export function useSnakeGame() {
const pauseGame = useGameStore((s) => s.pauseGame);
const resumeGame = useGameStore((s) => s.resumeGame);
const resetGame = useGameStore((s) => s.resetGame);
const setDirection = useGameStore((s) => s.setDirection);
const storeSetDirection = useGameStore((s) => s.setDirection);
const setDifficulty = useGameStore((s) => s.setDifficulty);
const setMode = useGameStore((s) => s.setMode);
const saveToLeaderboard = useGameStore((s) => s.saveToLeaderboard);

const statusRef = useRef(status);
statusRef.current = status;

const speed = DIFFICULTY_SPEEDS[difficulty];
const speedRef = useRef(speed);
speedRef.current = speed;

const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

const runTick = useCallback(() => {
if (statusRef.current === 'playing') {
useGameStore.getState().moveSnake();
}
}, []);

const scheduleTick = useCallback(() => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
runTick();
if (statusRef.current === 'playing') scheduleTick();
}, speedRef.current);
}, [runTick]);

const setDirection = storeSetDirection;

const handleKeyPress = useCallback(
(event: KeyboardEvent) => {
const key = event.key.toLowerCase();
Expand Down Expand Up @@ -67,17 +89,22 @@ export function useSnakeGame() {
return () => window.removeEventListener('keydown', handleKeyPress);
}, [handleKeyPress]);

const speed = DIFFICULTY_SPEEDS[difficulty];

useEffect(() => {
if (status !== 'playing') return;

const gameInterval = setInterval(() => {
useGameStore.getState().moveSnake();
}, speed);

return () => clearInterval(gameInterval);
}, [status, speed]);
if (status !== 'playing') {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
return;
}
scheduleTick();
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
};
}, [status, scheduleTick]);

const remainingFood = mode === 'casual' ? Math.max(0, FOOD_TO_WIN_CASUAL - score) : null;

Expand Down
12 changes: 9 additions & 3 deletions src/features/snake-game/store/useGameStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
FOOD_TYPES,
COMBO_WINDOW_MS,
PARTICLE_LIFETIME_MS,
MAX_DIRECTION_QUEUE,
} from '../constants';

function pickFoodType(): FoodType {
Expand Down Expand Up @@ -207,14 +208,19 @@ export const useGameStore = create<GameStore>((set, get) => ({
const { direction: currentDirection, directionQueue, status } = get();
if (status !== 'playing') return;

// drop inputs when queue is saturated — prevents input lag from spam
if (directionQueue.length >= MAX_DIRECTION_QUEUE) return;

const lastDirection =
directionQueue.length > 0
? directionQueue[directionQueue.length - 1]
: currentDirection;

if (OPPOSITE_DIRECTIONS[direction] !== lastDirection && direction !== lastDirection) {
set({ directionQueue: [...directionQueue, direction] });
}
// reject duplicates (same as pending last) and opposite (180 turn)
if (direction === lastDirection) return;
if (OPPOSITE_DIRECTIONS[direction] === lastDirection) return;

set({ directionQueue: [...directionQueue, direction] });
},

clearParticle: (id: number) => {
Expand Down
3 changes: 3 additions & 0 deletions src/features/snake-game/styles/snake-game.css
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,9 @@
border: 1px solid var(--sg-color-border);
border-radius: var(--sg-radius);
cursor: pointer;
touch-action: manipulation;
user-select: none;
-webkit-tap-highlight-color: transparent;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
transition:
border-color var(--sg-transition),
Expand Down
Loading