diff --git a/example/src/App.tsx b/example/src/App.tsx
index 58feba4..cf4a8f6 100755
--- a/example/src/App.tsx
+++ b/example/src/App.tsx
@@ -3,7 +3,7 @@ import "@vimazing/vim-snake/game.css";
import { useKeyBindings } from "./useKeyBindings";
function App() {
- const { containerRef, gameStatus, scoreManager, keyLog, score } = useGame(30, 20, useKeyBindings);
+ const { containerRef, gameStatus, scoreManager, keyLog, score, level } = useGame(30, 20, useKeyBindings);
const formatTime = (ms: number) => {
const seconds = Math.floor(ms / 1000);
@@ -20,6 +20,9 @@ function App() {
Time: {formatTime(scoreManager.timeValue)}
+
+ Level: {level}
+
Score: {score}
diff --git a/src/game.css b/src/game.css
index 5b99c1d..92a38c2 100755
--- a/src/game.css
+++ b/src/game.css
@@ -99,6 +99,33 @@
border-radius: 2px 6px 6px 2px;
}
+/* =========================================================
+ Collision Effect
+ ========================================================= */
+.snake-cell.collision {
+ background-color: var(--destructive, #c53b53) !important;
+ box-shadow:
+ 0 0 12px color-mix(in srgb, var(--destructive, #c53b53) 90%, transparent),
+ 0 0 24px color-mix(in srgb, var(--destructive, #c53b53) 60%, transparent),
+ inset 0 0 8px color-mix(in srgb, var(--destructive, #c53b53) 70%, transparent) !important;
+ animation: collision-pulse 0.5s ease-in-out infinite alternate;
+}
+
+@keyframes collision-pulse {
+ 0% {
+ box-shadow:
+ 0 0 12px color-mix(in srgb, var(--destructive, #c53b53) 90%, transparent),
+ 0 0 24px color-mix(in srgb, var(--destructive, #c53b53) 60%, transparent),
+ inset 0 0 8px color-mix(in srgb, var(--destructive, #c53b53) 70%, transparent);
+ }
+ 100% {
+ box-shadow:
+ 0 0 20px color-mix(in srgb, var(--destructive, #c53b53) 100%, transparent),
+ 0 0 40px color-mix(in srgb, var(--destructive, #c53b53) 80%, transparent),
+ inset 0 0 12px color-mix(in srgb, var(--destructive, #c53b53) 90%, transparent);
+ }
+}
+
/* =========================================================
Food Rendering
========================================================= */
diff --git a/src/useGame/useGame.ts b/src/useGame/useGame.ts
index 883ae2c..87611b2 100755
--- a/src/useGame/useGame.ts
+++ b/src/useGame/useGame.ts
@@ -16,7 +16,7 @@ export function useGame(cols: number, rows: number, platformHook?: unknown) {
const foodManager = useFood(cols, rows, rendererManager);
const gameManager = useGameStatus(rendererManager, snakeManager, foodManager);
- const { gameStatus, startGame, stopGame, score } = gameManager;
+ const { gameStatus, startGame, stopGame, score, level } = gameManager;
useEffect(() => {
renderBoard(cols, rows);
@@ -37,6 +37,7 @@ export function useGame(cols: number, rows: number, platformHook?: unknown) {
startGame,
stopGame,
score,
+ level,
...snakeManager,
...foodManager,
...keyBindings,
diff --git a/src/useGame/useGameStatus.ts b/src/useGame/useGameStatus.ts
index ee1ac51..e16d7cb 100755
--- a/src/useGame/useGameStatus.ts
+++ b/src/useGame/useGameStatus.ts
@@ -4,7 +4,8 @@ import type { UseRendererType } from "./useRenderer";
import type { UseSnakeType } from "./useSnake";
import type { UseFoodType } from "./useFood";
-const FRAMES_PER_SECOND = 5;
+const INITIAL_FPS = 5;
+const FOODS_PER_LEVEL = 2;
export function useGameStatus(
rendererManager: UseRendererType,
@@ -16,10 +17,14 @@ export function useGameStatus(
const { spawnFood, clearFood } = foodManager;
const [gameStatus, setGameStatus] = useState("waiting");
const [score, setScore] = useState(0);
+ const [level, setLevel] = useState(1);
+ const foodsEatenRef = useRef(0);
const gameLoopRef = useRef(null);
const lastUpdateRef = useRef(0);
const snakeManagerRef = useRef(snakeManager);
const foodManagerRef = useRef(foodManager);
+ const currentFpsRef = useRef(INITIAL_FPS);
+ const collisionGracePeriodRef = useRef(false);
snakeManagerRef.current = snakeManager;
foodManagerRef.current = foodManager;
@@ -28,6 +33,10 @@ export function useGameStatus(
initSnake();
spawnFood(snakeBodyRef.current, 3);
setScore(0);
+ setLevel(1);
+ foodsEatenRef.current = 0;
+ currentFpsRef.current = INITIAL_FPS;
+ collisionGracePeriodRef.current = false;
setGameStatus("started");
};
@@ -40,8 +49,8 @@ export function useGameStatus(
clearFood();
setGameStatus("waiting");
const container = containerRef.current;
- container?.querySelectorAll(".snake-head, .snake-body, .snake-tail").forEach((el) => {
- el.classList.remove("snake-head", "snake-body", "snake-tail");
+ container?.querySelectorAll(".snake-head, .snake-body, .snake-tail, .collision").forEach((el) => {
+ el.classList.remove("snake-head", "snake-body", "snake-tail", "collision");
});
};
@@ -51,10 +60,10 @@ export function useGameStatus(
console.log("Starting game loop");
let shouldGrow = false;
- const msPerFrame = 1000 / FRAMES_PER_SECOND;
lastUpdateRef.current = performance.now();
const gameLoop = (timestamp: number) => {
+ const msPerFrame = 1000 / currentFpsRef.current;
const elapsed = timestamp - lastUpdateRef.current;
if (elapsed >= msPerFrame) {
@@ -64,17 +73,44 @@ export function useGameStatus(
shouldGrow = false;
- if (result === "wall-collision") {
- setGameStatus("game-over");
- return;
+ if (result === "wall-collision" || result === "self-collision") {
+ if (collisionGracePeriodRef.current) {
+ const container = containerRef.current;
+ container?.querySelectorAll(".snake-head, .snake-body, .snake-tail").forEach((el) => {
+ el.classList.add("collision");
+ });
+ setGameStatus("game-over");
+ return;
+ } else {
+ collisionGracePeriodRef.current = true;
+ lastUpdateRef.current = timestamp;
+ gameLoopRef.current = requestAnimationFrame(gameLoop);
+ return;
+ }
}
+
+ collisionGracePeriodRef.current = false;
const head = snakeManagerRef.current.snakeBodyRef.current[0];
if (head && foodManagerRef.current.checkFoodCollision(head)) {
console.log("Food eaten!");
foodManagerRef.current.removeFood(head);
shouldGrow = true;
- setScore((prev) => prev + 1);
+
+ foodsEatenRef.current += 1;
+
+ if (foodsEatenRef.current >= FOODS_PER_LEVEL) {
+ setLevel((lvl) => {
+ const newLevel = lvl + 1;
+ currentFpsRef.current = INITIAL_FPS + (newLevel - 1);
+ console.log(`Level up! Level ${newLevel}, FPS: ${currentFpsRef.current}`);
+ return newLevel;
+ });
+ foodsEatenRef.current = 0;
+ }
+
+ setScore((prev) => prev + level);
+
foodManagerRef.current.spawnFood(
snakeManagerRef.current.snakeBodyRef.current,
1
@@ -114,6 +150,7 @@ export function useGameStatus(
startGame,
stopGame,
score,
+ level,
};
}
diff --git a/src/useGame/useSnake.ts b/src/useGame/useSnake.ts
index 6c30688..4264a4c 100755
--- a/src/useGame/useSnake.ts
+++ b/src/useGame/useSnake.ts
@@ -12,6 +12,7 @@ export function useSnake(
const snakeBodyRef = useRef([]);
const [direction, setDirection] = useState("up");
const directionRef = useRef("up");
+ const lastMovedDirectionRef = useRef("up");
const initSnake = () => {
const centerR = Math.floor(rows / 2);
@@ -27,6 +28,7 @@ export function useSnake(
snakeBodyRef.current = initialBody;
setDirection("up");
directionRef.current = "up";
+ lastMovedDirectionRef.current = "up";
renderSnake(initialBody);
};
@@ -35,6 +37,7 @@ export function useSnake(
snakeBodyRef.current = [];
setDirection("up");
directionRef.current = "up";
+ lastMovedDirectionRef.current = "up";
};
const renderSnake = (body: SnakeBody) => {
@@ -100,7 +103,7 @@ export function useSnake(
right: "left",
};
- if (opposites[directionRef.current] !== newDirection) {
+ if (opposites[lastMovedDirectionRef.current] !== newDirection) {
setDirection(newDirection);
directionRef.current = newDirection;
}
@@ -136,10 +139,23 @@ export function useSnake(
}
const newHead = { r: newR, c: newC };
+
+ // Check self-collision (excluding tail if not growing, as tail will move)
+ const bodyToCheck = grow ? body : body.slice(0, -1);
+ const collision = bodyToCheck.some(
+ (segment) => segment.r === newHead.r && segment.c === newHead.c
+ );
+
+ if (collision) {
+ return "self-collision";
+ }
+
const newBody = grow
? [newHead, ...body]
: [newHead, ...body.slice(0, -1)];
+ lastMovedDirectionRef.current = dir;
+
setSnakeBody(newBody);
snakeBodyRef.current = newBody;
renderSnake(newBody);