UNPKG

snake-ai-game

Version:

A terminal-based snake game powered by AI.

437 lines (376 loc) 16.4 kB
import chalk from 'chalk'; import { Direction, GameState, ActionType } from './types.js'; import { clearScreen, formatTime } from './utils.js'; import { findShortestPathToFood, getValidMoves, calculateNewPosition, analyzeSpatialState, suggestOptimalMove } from './gameLogic.js'; const COLORS = { PATH: chalk.rgb(120, 255, 120), FOOD: chalk.rgb(255, 100, 100), DANGER: chalk.rgb(255, 50, 50), SAFE: chalk.rgb(50, 180, 255), SNAKE: chalk.rgb(200, 200, 200), HEAD: chalk.rgb(255, 255, 100), HIGHLIGHT: chalk.rgb(255, 215, 0), OBSTACLE: chalk.rgb(150, 75, 0), CONFIDENCE: { HIGH: chalk.rgb(50, 255, 50), MEDIUM: chalk.rgb(255, 255, 50), LOW: chalk.rgb(255, 100, 50) }, INFO: chalk.rgb(100, 200, 255), AUTO: chalk.rgb(100, 255, 200), AI: chalk.rgb(255, 180, 100), DANGER_ZONE: chalk.bgRgb(100, 0, 0).rgb(255, 255, 255), SAFE_ZONE: chalk.bgRgb(0, 100, 0).rgb(255, 255, 255), NEUTRAL_ZONE: chalk.bgRgb(70, 70, 70).rgb(255, 255, 255) }; const PATH_SYMBOLS = { UP: '↑', DOWN: '↓', LEFT: '←', RIGHT: '→' }; const DIRECTION_EMOJI = { UP: '⬆️', DOWN: '⬇️', LEFT: '⬅️', RIGHT: '➡️', SCAN: '🔍' }; const MAX_HISTORY_OCCURRENCES = 8; const moveHistory: Array<{ timestamp: number, gameTime: number, action: ActionType, tool: string, confidence?: number, safetyScore?: number, pathLength?: number, openSpaces?: number, isAutoPilot?: boolean, isAIMove?: boolean, moveRemaining?: number }> = []; const groupedHistory: Array<{ action: ActionType, count: number, startTime: number, endTime: number, tool: string, confidence?: number, safetyScore?: number, pathLength?: number, openSpaces?: number, isAutoPilot?: boolean, isAIMove?: boolean, moveRemaining?: number }> = []; let gameStartTime = Date.now(); let tokenUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0, history: [] as Array<{ timestamp: number, prompt: number, completion: number, total: number }> }; export const addMoveToHistory = ( action: ActionType, tool: string = "move", metrics: { confidence?: number, safetyScore?: number, pathLength?: number, openSpaces?: number, isAutoPilot?: boolean, isAIMove?: boolean, moveRemaining?: number } = {} ): void => { const currentTime = Date.now(); const gameElapsedTime = Math.floor((currentTime - gameStartTime) / 1000); moveHistory.push({ timestamp: currentTime, gameTime: gameElapsedTime, action, tool, ...metrics }); const lastGroup = groupedHistory.length > 0 ? groupedHistory[groupedHistory.length - 1] : null; if (lastGroup && lastGroup.action === action && lastGroup.tool === tool && lastGroup.isAutoPilot === metrics.isAutoPilot && lastGroup.isAIMove === metrics.isAIMove) { lastGroup.count++; lastGroup.endTime = gameElapsedTime; if (metrics.confidence !== undefined) lastGroup.confidence = metrics.confidence; if (metrics.safetyScore !== undefined) lastGroup.safetyScore = metrics.safetyScore; if (metrics.pathLength !== undefined) lastGroup.pathLength = metrics.pathLength; if (metrics.openSpaces !== undefined) lastGroup.openSpaces = metrics.openSpaces; if (metrics.moveRemaining !== undefined) lastGroup.moveRemaining = metrics.moveRemaining; } else { groupedHistory.push({ action, count: 1, startTime: gameElapsedTime, endTime: gameElapsedTime, tool, ...metrics, }); if (groupedHistory.length > MAX_HISTORY_OCCURRENCES) groupedHistory.shift(); } }; export const updateTokenUsage = ( usage: { promptTokens: number, completionTokens: number, totalTokens: number }, response?: any ): void => { let promptTokens = usage.promptTokens || 0; let completionTokens = usage.completionTokens || 0; if (response?.body?.usageMetadata) { promptTokens = response.body.usageMetadata.promptTokenCount || promptTokens; completionTokens = response.body.usageMetadata.candidatesTokenCount || completionTokens; } const totalTokens = promptTokens + completionTokens; tokenUsage.promptTokens += promptTokens; tokenUsage.completionTokens += completionTokens; tokenUsage.totalTokens += totalTokens; tokenUsage.history.push({ timestamp: Date.now(), prompt: promptTokens, completion: completionTokens, total: totalTokens, }); if (tokenUsage.history.length > 10) { tokenUsage.history.shift(); } }; export const renderGameFrame = async ( state: GameState, moveNumber: number, _decisionMetrics: Record<string, any> = {}, _showThinking = false, timeRemaining?: number ): Promise<void> => { clearScreen(); if (moveNumber === 0) { gameStartTime = Date.now(); moveHistory.length = 0; groupedHistory.length = 0; tokenUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0, history: [] }; } if (_decisionMetrics.scanning) { addMoveToHistory('SCAN', 'scan', { confidence: 1, safetyScore: 0, pathLength: 0, openSpaces: 0 }); } else if (_decisionMetrics.direction) { const spatialAnalysis = analyzeSpatialState(state); const pathToFood = findShortestPathToFood(state); const safetyScore = spatialAnalysis.safetyScores?.[_decisionMetrics.direction as Direction] || 0; addMoveToHistory( _decisionMetrics.direction, _decisionMetrics.isAIMove ? 'ai_move' : 'auto_move', { confidence: _decisionMetrics.confidence, safetyScore, pathLength: pathToFood.length, openSpaces: spatialAnalysis.openSpaces, isAutoPilot: _decisionMetrics.isAutoPilot, isAIMove: _decisionMetrics.isAIMove, moveRemaining: _decisionMetrics.moveRemaining, } ); } const layout = renderUnifiedLayout(state, moveNumber, timeRemaining, _decisionMetrics, _showThinking); console.log(layout); if (state.isGameOver) { console.log(timeRemaining === 0 ? chalk.magenta('=== TIME\'S UP! ===') : chalk.red('=== GAME OVER ===')); console.log(chalk.yellow(`Final Score: ${state.score}`)); } }; const renderUnifiedLayout = ( state: GameState, moveNumber: number, timeRemaining?: number, _decisionMetrics: Record<string, any> = {}, _showThinking = false ): string => { const { gridSize, snake, food, score, obstacles = [], visibilityRadius } = state; const optimalPath = findShortestPathToFood(state); const head = snake[0]; const spatialAnalysis = analyzeSpatialState(state); const suggestion = suggestOptimalMove(state); // const gameGridWidth = gridSize * 2 + 2; // Unused // const visualGridWidth = gridSize * 2 + 2; // Unused const spacing = 3; const lines: string[] = []; lines.push(chalk.cyan(`=== Snake Game ===`) + ' '.repeat(15) + chalk.cyan('=== AI Thinking Board ===')); const timeDisplay = timeRemaining !== undefined ? formatTime(timeRemaining) : ''; lines.push(chalk.gray(`Move ${moveNumber} | Score: ${score}${timeRemaining !== undefined ? ` | Time: ${chalk.yellow(timeDisplay)}` : ''}`)); lines.push(''); // Build path data const pathData: Array<{x: number, y: number, dir: Direction}> = []; if (optimalPath.length > 0) { let curPos = {...snake[0]}; for (let i = 0; i < optimalPath.length; i++) { const dir = optimalPath[i]; curPos = calculateNewPosition(curPos, dir, gridSize); pathData.push({...curPos, dir}); } } // Build danger zones (potential traps/dead ends) const dangerZones = new Set<string>(); if (spatialAnalysis.potentialTraps) { spatialAnalysis.potentialTraps.forEach(trap => { dangerZones.add(`${trap.x},${trap.y}`); }); } // Game grid rendering const gameGrid: string[][] = Array(gridSize).fill(0).map(() => Array(gridSize).fill('..')); if (snake[0].y < gridSize && snake[0].x < gridSize) gameGrid[snake[0].y][snake[0].x] = '👀'; for (const segment of snake.slice(1)) { if (segment.y < gridSize && segment.x < gridSize) gameGrid[segment.y][segment.x] = '🟢'; } if (food.y < gridSize && food.x < gridSize) gameGrid[food.y][food.x] = '🍎'; for (const obstacle of obstacles) { if (obstacle.y < gridSize && obstacle.x < gridSize) gameGrid[obstacle.y][obstacle.x] = '🧱'; } // Visual grid for AI thinking const visualGrid: string[][] = Array(gridSize).fill(0).map(() => Array(gridSize).fill('..')); // Add visibility limits and obstacles for (let y = 0; y < gridSize; y++) { for (let x = 0; x < gridSize; x++) { if (visibilityRadius !== undefined) { const distanceToHead = Math.max(Math.abs(x - head.x), Math.abs(y - head.y)); if (distanceToHead > visibilityRadius) { visualGrid[y][x] = '??'; continue; } } if (obstacles.some(o => o.x === x && o.y === y)) { visualGrid[y][x] = '##'; } } } // Add snake, food and path to visual grid if (snake[0].y < gridSize && snake[0].x < gridSize) visualGrid[snake[0].y][snake[0].x] = '👀'; for (const segment of snake.slice(1)) { if (segment.y < gridSize && segment.x < gridSize && visualGrid[segment.y][segment.x] === '..') visualGrid[segment.y][segment.x] = '◯◯'; } if (food.y < gridSize && food.x < gridSize && visualGrid[food.y][food.x] === '..') visualGrid[food.y][food.x] = '🍎'; // Add direction indicators and safety zones const validMoves = getValidMoves(state); for (const dir of validMoves) { const nextPos = calculateNewPosition(head, dir, gridSize); if (nextPos.y < gridSize && nextPos.x < gridSize && visualGrid[nextPos.y][nextPos.x] === '..') { const safetyScore = spatialAnalysis.safetyScores?.[dir] || 0; if (dangerZones.has(`${nextPos.x},${nextPos.y}`)) { visualGrid[nextPos.y][nextPos.x] = `${PATH_SYMBOLS[dir]}!`; } else if (dir === suggestion.direction) { visualGrid[nextPos.y][nextPos.x] = `${PATH_SYMBOLS[dir]}${PATH_SYMBOLS[dir]}`; } else { visualGrid[nextPos.y][nextPos.x] = `${PATH_SYMBOLS[dir]}${safetyScore}`; } } } // Add optimal path for (const {x, y, dir} of pathData) { if (y < gridSize && x < gridSize && (visualGrid[y][x] === '..' || visualGrid[y][x].includes(PATH_SYMBOLS[dir]))) { visualGrid[y][x] = PATH_SYMBOLS[dir].repeat(2); } } // Render grids const gameGridTopBorder = chalk.cyan('+' + '-'.repeat(gridSize * 2) + '+'); const visualGridTopBorder = chalk.cyan('+' + '-'.repeat(gridSize * 2) + '+'); lines.push(gameGridTopBorder + ' '.repeat(spacing) + visualGridTopBorder); for (let y = 0; y < gridSize; y++) { let gameLine = chalk.cyan('|'); for (let x = 0; x < gridSize; x++) gameLine += gameGrid[y][x]; gameLine += chalk.cyan('|'); let visualLine = chalk.cyan('|'); for (let x = 0; x < gridSize; x++) { const cell = visualGrid[y][x]; if (cell === '👀') { visualLine += cell; } else if (cell === '◯◯') { visualLine += COLORS.SNAKE('◯◯'); } else if (cell === '🍎') { visualLine += cell; } else if (cell === '##') { visualLine += COLORS.OBSTACLE('##'); } else if (cell === '??') { visualLine += chalk.gray('??'); } else if (cell.endsWith('!')) { visualLine += COLORS.DANGER_ZONE(cell); } else if (cell === '↑↑' || cell === '↓↓' || cell === '←←' || cell === '→→') { visualLine += COLORS.PATH(cell); } else if (cell.length === 2 && (cell.startsWith('↑') || cell.startsWith('↓') || cell.startsWith('←') || cell.startsWith('→'))) { const safetyScore = parseInt(cell[1]); if (safetyScore >= 3) { visualLine += COLORS.SAFE_ZONE(cell); } else if (safetyScore <= 1) { visualLine += COLORS.DANGER_ZONE(cell); } else { visualLine += COLORS.NEUTRAL_ZONE(cell); } } else { visualLine += '..'; } } visualLine += chalk.cyan('|'); lines.push(gameLine + ' '.repeat(spacing) + visualLine); } const gameGridBottomBorder = chalk.cyan('+' + '-'.repeat(gridSize * 2) + '+'); const visualGridBottomBorder = chalk.cyan('+' + '-'.repeat(gridSize * 2) + '+'); lines.push(gameGridBottomBorder + ' '.repeat(spacing) + visualGridBottomBorder); // Only show legend, AI thoughts, decision history, and tools when game is not over if (!state.isGameOver) { // Render legend lines.push(''); lines.push(chalk.cyan('=== Legend ===')); lines.push(`👀 Snake head 🍎 Food 🟢/◯◯ Snake body 🧱/## Obstacle ${COLORS.PATH('↑↑')} Optimal path`); lines.push(`${COLORS.SAFE_ZONE('↑3')} Safe move ${COLORS.NEUTRAL_ZONE('↑2')} Neutral move ${COLORS.DANGER_ZONE('↑1')} Risky move ${COLORS.DANGER_ZONE('↑!')} Danger zone`); // Render thinking stats if (groupedHistory.length > 0) { lines.push(''); lines.push(chalk.cyan('=== AI Thoughts ===')); lines.push(`Score: ${chalk.yellow(score.toString())} Open Space: ${COLORS.INFO(spatialAnalysis.openSpaces.toString())} Regions: ${spatialAnalysis.connectedRegions}`); const confidenceValue = Math.round(suggestion.confidence * 100); let confidenceColor = COLORS.CONFIDENCE.MEDIUM; if (suggestion.confidence >= 0.7) confidenceColor = COLORS.CONFIDENCE.HIGH; else if (suggestion.confidence < 0.4) confidenceColor = COLORS.CONFIDENCE.LOW; const directionEmoji = DIRECTION_EMOJI[suggestion.direction] || ''; lines.push(`Suggested move: ${directionEmoji} ${suggestion.direction} ${confidenceColor(`(${confidenceValue}%)`)} Path to food: ${spatialAnalysis.optimalPathLength ? COLORS.PATH(`${spatialAnalysis.optimalPathLength} steps`) : COLORS.DANGER('None')}`); // Render decision history lines.push(''); lines.push(chalk.cyan('=== Decision History ===')); groupedHistory.forEach(group => { const dirEmoji = DIRECTION_EMOJI[group.action as keyof typeof DIRECTION_EMOJI] || ''; const timeInfo = group.count > 1 ? `${group.startTime}s-${group.endTime}s` : `${group.startTime}s`; let confidenceText = '-'; if (group.confidence !== undefined) { const confValue = Math.round(group.confidence * 100); let confColor = COLORS.CONFIDENCE.MEDIUM; if (group.confidence >= 0.7) confColor = COLORS.CONFIDENCE.HIGH; else if (group.confidence < 0.4) confColor = COLORS.CONFIDENCE.LOW; confidenceText = confColor(`${confValue}%`); } const safetyText = group.safetyScore !== undefined ? COLORS.SAFE(`${group.safetyScore}`) : '-'; const pathText = group.pathLength !== undefined ? COLORS.PATH(`${group.pathLength}`) : '-'; let displayText = `${timeInfo}: ${dirEmoji} ${group.action} ${confidenceText} Safety: ${safetyText} Path: ${pathText}`; if (group.tool === 'scan') { displayText = `${timeInfo}: ${dirEmoji} SCAN`; } else if (group.isAutoPilot) { const moreInfo = group.moveRemaining !== undefined ? ` +${group.moveRemaining} more` : ''; displayText = `${timeInfo}: ${COLORS.AUTO(`🚀 AUTO ${group.action}`)} ${confidenceText} Path: ${pathText}${moreInfo}`; } else if (group.isAIMove) { displayText = `${timeInfo}: ${COLORS.AI(`🧠 AI ${group.action}`)} ${confidenceText} Safety: ${safetyText} Path: ${pathText}`; } lines.push(displayText); }); // Render token usage lines.push(''); lines.push(chalk.cyan('=== Token Usage ===')); const tokenHistory = tokenUsage.history.map(h => `${h.prompt}/${h.completion}`).join(' | '); lines.push(`Total: ${chalk.yellow(tokenUsage.totalTokens)} (${chalk.cyan(tokenUsage.promptTokens)}P/${chalk.cyan(tokenUsage.completionTokens)}C)`); lines.push(`History: ${tokenHistory}`); } lines.push(''); lines.push(chalk.cyan('=== Available Tools ===')); lines.push(`- scan: Analyze game state and get optimal moves.`); lines.push(`- move: Move the snake in a sequence of up to 5 directions.`); lines.push(`- giveUp: Give up the game if stuck.`); } return lines.join('\n'); };