snake-ai-game
Version:
A terminal-based snake game powered by AI.
225 lines (174 loc) • 7.43 kB
text/typescript
import { Position, Direction, GameState, SpatialAnalysis } from './types.js';
import { getPositionKey } from './utils.js';
export const calculateNewPosition = (position: Position, direction: Direction, gridSize: number): Position => {
const directions = {
UP: { x: 0, y: -1 },
DOWN: { x: 0, y: 1 },
LEFT: { x: -1, y: 0 },
RIGHT: { x: 1, y: 0 }
};
const delta = directions[direction];
return {
x: (position.x + delta.x + gridSize) % gridSize,
y: (position.y + delta.y + gridSize) % gridSize
};
};
export const isValidMove = (direction: Direction, state: GameState): boolean => {
const newHeadPos = calculateNewPosition(state.snake[0], direction, state.gridSize);
if (state.obstacles?.some(obs => obs.x === newHeadPos.x && obs.y === newHeadPos.y))
return false;
return !state.snake.slice(1).some(segment => segment.x === newHeadPos.x && segment.y === newHeadPos.y);
};
export const getValidMoves = (state: GameState): Direction[] =>
(['UP', 'DOWN', 'LEFT', 'RIGHT'] as const)
.filter(direction => isValidMove(direction, state));
export const findShortestPathToFood = (state: GameState): Direction[] => {
const { snake, food, gridSize, obstacles = [] } = state;
const head = snake[0];
const obstacleSet = new Set(obstacles.map(getPositionKey));
const visited = new Set([...snake.slice(1).map(getPositionKey), ...obstacleSet]);
const queue: { pos: Position; path: Direction[] }[] = [];
['UP', 'DOWN', 'LEFT', 'RIGHT'].forEach(direction => {
const nextPos = calculateNewPosition(head, direction as Direction, gridSize);
const posKey = getPositionKey(nextPos);
if (!visited.has(posKey)) {
queue.push({ pos: nextPos, path: [direction as Direction] });
visited.add(posKey);
}
});
while (queue.length > 0) {
const { pos, path } = queue.shift()!;
if (pos.x === food.x && pos.y === food.y) return path;
if (state.visibilityRadius &&
Math.max(Math.abs(pos.x - head.x), Math.abs(pos.y - head.y)) > state.visibilityRadius) {
continue;
}
['UP', 'DOWN', 'LEFT', 'RIGHT'].forEach(direction => {
const nextPos = calculateNewPosition(pos, direction as Direction, gridSize);
const posKey = getPositionKey(nextPos);
if (!visited.has(posKey)) {
queue.push({ pos: nextPos, path: [...path, direction as Direction] });
visited.add(posKey);
}
});
}
return [];
};
const calculateSafety = (pos: Position, state: GameState): number => {
const { gridSize, snake, obstacles = [] } = state;
let safeExits = 0;
['UP', 'DOWN', 'LEFT', 'RIGHT'].forEach(dir => {
const nextPos = calculateNewPosition(pos, dir as Direction, gridSize);
const isSafe = !snake.some(segment => segment.x === nextPos.x && segment.y === nextPos.y) &&
!obstacles.some(obs => obs.x === nextPos.x && obs.y === nextPos.y);
if (isSafe) safeExits++;
});
return safeExits;
};
export const analyzeSpatialState = (state: GameState): SpatialAnalysis => {
const { snake, food, gridSize, obstacles = [] } = state;
const head = snake[0];
const snakeSet = new Set(snake.map(getPositionKey));
const obstacleSet = new Set(obstacles.map(getPositionKey));
const floodFill = (startPos: Position): Set<string> => {
const visited = new Set<string>();
const queue: Position[] = [startPos];
while (queue.length > 0) {
const pos = queue.shift()!;
const posKey = getPositionKey(pos);
if (visited.has(posKey) || snakeSet.has(posKey) || obstacleSet.has(posKey)) continue;
visited.add(posKey);
['UP', 'DOWN', 'LEFT', 'RIGHT'].forEach(dir => {
const nextPos = calculateNewPosition(pos, dir as Direction, gridSize);
queue.push(nextPos);
});
}
return visited;
};
const connectedRegions: Set<string>[] = [];
for (let y = 0; y < gridSize; y++) {
for (let x = 0; x < gridSize; x++) {
const posKey = getPositionKey({x, y});
if (snakeSet.has(posKey) || obstacleSet.has(posKey)) continue;
const alreadyInRegion = connectedRegions.some(region => region.has(posKey));
if (!alreadyInRegion) connectedRegions.push(floodFill({x, y}));
}
}
const distanceToFood = Math.abs(head.x - food.x) + Math.abs(head.y - food.y);
const pathToFood = findShortestPathToFood(state);
const openSpaces = gridSize * gridSize - snake.length - (obstacles?.length || 0);
const potentialTraps: Position[] = [];
const safetyScores: Record<Direction, number> = {} as Record<Direction, number>;
const directions = ['UP', 'DOWN', 'LEFT', 'RIGHT'] as const;
directions.forEach(dir => {
const nextPos = calculateNewPosition(head, dir, gridSize);
if (!snake.slice(1).some(s => s.x === nextPos.x && s.y === nextPos.y) &&
!obstacleSet.has(getPositionKey(nextPos))) {
const nextState = {
...state,
snake: [nextPos, ...snake.slice(0, snake.length - 1)]
};
const validMoves = getValidMoves(nextState);
safetyScores[dir] = calculateSafety(nextPos, state);
if (validMoves.length <= 1) potentialTraps.push(nextPos);
}
});
const obstaclesInPath = pathToFood.reduce((count, dir) => {
const nextPos = calculateNewPosition(head, dir, gridSize);
return count + (obstacleSet.has(getPositionKey(nextPos)) ? 1 : 0);
}, 0);
return {
openSpaces,
connectedRegions: connectedRegions.length,
distanceToFood,
potentialTraps,
safePathExists: pathToFood.length > 0,
optimalPathLength: pathToFood.length || undefined,
obstaclesInPath,
safetyScores
};
};
export const suggestOptimalMove = (state: GameState): { direction: Direction, confidence: number, plannedPath?: Direction[] } => {
const analysis = analyzeSpatialState(state);
const pathToFood = findShortestPathToFood(state);
const validMoves = getValidMoves(state);
if (validMoves.length === 0) {
return { direction: state.direction, confidence: 0.1 };
}
if (validMoves.length === 1) {
return { direction: validMoves[0], confidence: 0.95 };
}
// If there's a clear path to food, follow it
if (pathToFood.length > 0) {
const nextMove = pathToFood[0];
// Check if this move leads to a trap
const willTrap = analysis.potentialTraps.some(trap => {
const nextPos = calculateNewPosition(state.snake[0], nextMove, state.gridSize);
return trap.x === nextPos.x && trap.y === nextPos.y;
});
if (!willTrap) {
return {
direction: nextMove,
confidence: 0.9 - (0.05 * Math.min(5, pathToFood.length)),
plannedPath: pathToFood
};
}
}
// Otherwise, choose the move with the highest safety score
let bestMove = validMoves[0];
let bestScore = -1;
validMoves.forEach(move => {
const safetyScore = analysis.safetyScores?.[move] || 0;
const newPos = calculateNewPosition(state.snake[0], move, state.gridSize);
const distanceToFood = Math.abs(newPos.x - state.food.x) + Math.abs(newPos.y - state.food.y);
const adjustedScore = safetyScore * 3 - distanceToFood * 0.5;
if (adjustedScore > bestScore) {
bestScore = adjustedScore;
bestMove = move;
}
});
return {
direction: bestMove,
confidence: 0.5 + (0.1 * (bestScore / 10))
};
};