mono/packages/ui/src/apps/tetris/Tetris.tsx
2026-02-08 15:09:32 +01:00

1073 lines
48 KiB
TypeScript

import React, { useState, useEffect, useCallback, useRef } from 'react';
import { GameState, Position, BOARD_WIDTH, BOARD_HEIGHT } from './types';
import {
createEmptyBoard,
getRandomTetromino,
resetRandomizer,
rotateTetromino,
checkCollision,
mergePieceToBoard,
clearLines,
calculateScore,
getColorForCell,
getStartPosition,
getDropSpeed,
RandomizerMode,
} from './gameLogic';
import { findBestMove, AIWeights, DEFAULT_WEIGHTS, AdaptiveScaling, DEFAULT_ADAPTIVE_SCALING } from './aiPlayer';
import { Button } from '@/components/ui/button';
import { X } from 'lucide-react';
import { getNeuralNetwork, saveNeuralNetwork, trainOnHistory, getNeuralWeights, calculateReward, extractBoardFeatures, weightsToOutput, NETWORK_CONFIG, notifyGameEnd } from './neuralAI';
import { NeuralNetworkVisualizer } from './NeuralNetworkVisualizer';
import { WeightsHistoryChart } from './WeightsHistoryChart';
import { AIStrategyControl } from './AIStrategyControl';
import { PerformanceChart } from './PerformanceChart';
import { AIWeightsPanel } from './AIWeightsPanel';
import { ControlsPanel } from './ControlsPanel';
import { StatsPanel } from './StatsPanel';
import { NextPieceDisplay } from './NextPieceDisplay';
import { LearningLog } from './LearningLog';
import { TrainingDataModal } from './TrainingDataModal';
import { Database } from 'lucide-react';
import MarkdownRenderer from '@/components/MarkdownRenderer';
// Game history tracking
interface GameResult {
score: number;
lines: number;
level: number;
weights: AIWeights;
boardFeatures: number[]; // Average board state during the game
timestamp: number;
}
interface WeightChange {
timestamp: number;
gameNumber: number;
score: number;
lines: number;
oldWeights: AIWeights;
newWeights: AIWeights;
}
const saveGameResult = (score: number, lines: number, level: number, weights: AIWeights, boardFeatures: number[], isAutoTraining: boolean = true) => {
try {
const history: GameResult[] = JSON.parse(localStorage.getItem('tetris-game-history') || '[]');
history.push({
score,
lines,
level,
weights,
boardFeatures,
timestamp: Date.now(),
});
// Keep only last 1000 games for training
if (history.length > 1000) {
history.shift();
}
localStorage.setItem('tetris-game-history', JSON.stringify(history));
// Notify AI strategies (Hall of Fame, Auto-Revert)
notifyGameEnd({
score,
lines,
level,
weights,
boardFeatures,
timestamp: Date.now(),
}, history);
// Train neural network on new data ONLY if auto-training is enabled
if (isAutoTraining) {
const network = getNeuralNetwork();
trainOnHistory(network);
}
} catch (error) {
console.error("Failed to save game result:", error);
}
};
const Tetris: React.FC = () => {
const [gameState, setGameState] = useState<GameState>({
board: createEmptyBoard(),
currentPiece: null,
currentPosition: getStartPosition(),
nextPiece: getRandomTetromino(),
score: 0,
level: 1,
lines: 0,
gameOver: false,
isPaused: false,
isAutoPlay: true,
});
const [gameStarted, setGameStarted] = useState(false);
const [debugInfo, setDebugInfo] = useState<any>({
linesCleared: 0,
lineScore: 0,
contacts: 0,
contactScore: 0,
holes: 0,
holesCreated: 0,
holesCreatedPenalty: 0,
overhangs: 0,
overhangsCreated: 0,
overhangsCreatedPenalty: 0,
overhangsFilled: 0,
overhangsFilledBonus: 0,
heightAdded: 0,
heightAddedPenalty: 0,
wells: 0,
wellDepth: 0,
wellsPenalty: 0,
bumpiness: 0,
bumpinessPenalty: 0,
maxHeight: 0,
avgHeight: '0.0',
avgHeightPenalty: '0',
rowTransitions: 0,
rowTransitionsPenalty: 0,
totalScore: 0,
});
const [clearedLines, setClearedLines] = useState<number[]>([]);
const [aiWeights, setAiWeights] = useState<AIWeights>(() => {
const stored = localStorage.getItem('tetris-ai-weights');
if (stored) {
try {
const parsed = JSON.parse(stored);
// Filter out undefined/null values and merge with defaults
const cleanParsed = Object.fromEntries(
Object.entries(parsed).filter(([_, v]) => v !== undefined && v !== null && !isNaN(v as number))
);
return { ...DEFAULT_WEIGHTS, ...cleanParsed };
} catch {
return DEFAULT_WEIGHTS;
}
}
return DEFAULT_WEIGHTS;
});
const [aiMode, setAiMode] = useState<'standard' | 'neural'>('neural');
const [startLevel, setStartLevel] = useState(() => {
const stored = localStorage.getItem('tetris-start-level');
return stored ? parseInt(stored) : 40;
});
const [neuralNetwork] = useState(() => getNeuralNetwork());
const [currentNNInput, setCurrentNNInput] = useState<number[]>();
const [currentNNOutput, setCurrentNNOutput] = useState<number[]>();
const [isHelpOpen, setIsHelpOpen] = useState(false);
const [weightChanges, setWeightChanges] = useState<WeightChange[]>(() => {
const stored = localStorage.getItem('tetris-weight-changes');
return stored ? JSON.parse(stored) : [];
});
const [isMaxSpeed, setIsMaxSpeed] = useState(() => {
return localStorage.getItem('tetris-max-speed') === 'true';
});
const [gameCounter, setGameCounter] = useState(() => {
const stored = localStorage.getItem('tetris-game-counter');
return stored ? parseInt(stored) : 0;
});
const [gameHistory, setGameHistory] = useState<GameResult[]>(() => {
const stored = localStorage.getItem('tetris-game-history');
return stored ? JSON.parse(stored) : [];
});
const [adaptiveScaling, setAdaptiveScaling] = useState<AdaptiveScaling>(() => {
const stored = localStorage.getItem('tetris-adaptive-scaling');
return stored ? JSON.parse(stored) : DEFAULT_ADAPTIVE_SCALING;
});
const [randomizerMode, setRandomizerMode] = useState<RandomizerMode>(() => {
return (localStorage.getItem('tetris-randomizer-mode') as RandomizerMode) || 'tgm3';
});
const [activeTab, setActiveTab] = useState<'game' | 'manual' | 'log'>('game');
const [manualContent, setManualContent] = useState<string>('');
const gameLoopRef = useRef<number | null>(null);
const lastDropTimeRef = useRef<number>(0);
const aiMoveRef = useRef<any>(null);
const lastProcessedPieceRef = useRef<string | null>(null); // Track which piece we've processed
const aiStateRef = useRef<'idle' | 'calculating' | 'executing' | 'dropping'>('idle');
const aiStateStartTimeRef = useRef<number>(0);
const inactivityTimerRef = useRef<number | null>(null);
const boardFeaturesAccumulator = useRef<number[][]>([]); // Accumulate board features during game
// Load manual content
useEffect(() => {
fetch('/tetris.md')
.then(res => res.text())
.then(text => setManualContent(text))
.catch(err => console.error('Failed to load manual:', err));
}, []);
const handleMaxSpeedChange = (checked: boolean) => {
setIsMaxSpeed(checked);
localStorage.setItem('tetris-max-speed', String(checked));
};
const handleRandomizerModeChange = (mode: RandomizerMode) => {
setRandomizerMode(mode);
localStorage.setItem('tetris-randomizer-mode', mode);
// Reset randomizers to clear history/generators when switching modes
resetRandomizer();
};
const startGame = useCallback(() => {
// Save previous game result if it was a completed game
if (gameStarted && gameState.gameOver) {
// Calculate average board features from accumulator
const avgBoardFeatures = boardFeaturesAccumulator.current.length > 0
? boardFeaturesAccumulator.current[0].map((_, i) =>
boardFeaturesAccumulator.current.reduce((sum, features) => sum + features[i], 0) /
boardFeaturesAccumulator.current.length
)
: Array(10).fill(0.5); // Fallback if no features collected
saveGameResult(gameState.score, gameState.lines, gameState.level, aiWeights, avgBoardFeatures);
// Update game history state
const newResult: GameResult = {
score: gameState.score,
lines: gameState.lines,
level: gameState.level,
weights: aiWeights,
boardFeatures: avgBoardFeatures,
timestamp: Date.now(),
};
setGameHistory(prev => {
const updated = [...prev, newResult];
// Keep only last 1000 games
return updated.length > 1000 ? updated.slice(-1000) : updated;
});
}
// Reset board features accumulator for new game
boardFeaturesAccumulator.current = [];
// Load weights based on AI mode
const oldWeights = aiWeights;
const newWeights = aiMode === 'neural' ? getNeuralWeights() : aiWeights;
if (aiMode === 'neural') {
// Track weight changes
const newGameNumber = gameCounter + 1;
setGameCounter(newGameNumber);
localStorage.setItem('tetris-game-counter', newGameNumber.toString());
// Log the weight change ONLY if weights have actually changed
const areWeightsDifferent = (w1: AIWeights, w2: AIWeights) => {
return Object.keys(w1).some(key => {
const k = key as keyof AIWeights;
return Math.abs(w1[k] - w2[k]) > 0.001;
});
};
if (areWeightsDifferent(oldWeights, newWeights)) {
const change: WeightChange = {
timestamp: Date.now(),
gameNumber: newGameNumber,
score: gameState.score,
lines: gameState.lines,
oldWeights,
newWeights,
};
let updatedChanges = [...weightChanges, change];
if (updatedChanges.length > 1000) {
updatedChanges = updatedChanges.slice(-1000);
}
setWeightChanges(updatedChanges);
try {
localStorage.setItem('tetris-weight-changes', JSON.stringify(updatedChanges));
} catch (e) {
console.error("Failed to save weight changes", e);
}
}
setAiWeights(newWeights);
}
aiMoveRef.current = null;
lastProcessedPieceRef.current = null;
aiStateRef.current = 'idle';
setClearedLines([]);
setDebugInfo({
linesCleared: 0,
lineScore: 0,
contacts: 0,
contactScore: 0,
holes: 0,
holesCreated: 0,
holesCreatedPenalty: 0,
overhangs: 0,
overhangsCreated: 0,
overhangsCreatedPenalty: 0,
overhangsFilled: 0,
overhangsFilledBonus: 0,
heightAdded: 0,
heightAddedPenalty: 0,
wells: 0,
wellDepth: 0,
wellsPenalty: 0,
bumpiness: 0,
bumpinessPenalty: 0,
maxHeight: 0,
avgHeight: '0.0',
avgHeightPenalty: '0',
rowTransitions: 0,
rowTransitionsPenalty: 0,
totalScore: 0,
});
// Reset the TGM3 randomizer for a fresh start
resetRandomizer();
setGameState({
board: createEmptyBoard(),
currentPiece: getRandomTetromino(randomizerMode),
currentPosition: getStartPosition(),
nextPiece: getRandomTetromino(randomizerMode),
score: 0,
level: startLevel,
lines: 0,
gameOver: false,
isPaused: false,
isAutoPlay: true,
});
setGameStarted(true);
}, [gameStarted, gameState.gameOver, gameState.score, gameState.lines, gameState.level, aiWeights, aiMode, weightChanges, gameCounter, startLevel, randomizerMode]);
const movePiece = useCallback((dx: number, dy: number) => {
setGameState((prev) => {
if (!prev.currentPiece || prev.gameOver || prev.isPaused) return prev;
const newPosition: Position = {
x: prev.currentPosition.x + dx,
y: prev.currentPosition.y + dy,
};
if (!checkCollision(prev.board, prev.currentPiece, newPosition)) {
return { ...prev, currentPosition: newPosition };
}
return prev;
});
}, []);
const rotate = useCallback(() => {
setGameState((prev) => {
if (!prev.currentPiece || prev.gameOver || prev.isPaused) return prev;
const rotated = rotateTetromino(prev.currentPiece);
if (!checkCollision(prev.board, rotated, prev.currentPosition)) {
return { ...prev, currentPiece: rotated };
}
return prev;
});
}, []);
const hardDrop = useCallback(() => {
setGameState((prev) => {
if (!prev.currentPiece || prev.gameOver || prev.isPaused) return prev;
let newPosition = { ...prev.currentPosition };
while (!checkCollision(prev.board, prev.currentPiece, { x: newPosition.x, y: newPosition.y + 1 })) {
newPosition.y++;
}
return { ...prev, currentPosition: newPosition };
});
}, []);
const lockPiece = useCallback(() => {
setGameState((prev) => {
if (!prev.currentPiece) return prev;
const newBoard = mergePieceToBoard(prev.board, prev.currentPiece, prev.currentPosition);
const { newBoard: clearedBoard, linesCleared } = clearLines(newBoard);
// Find which lines were cleared for animation
const linesToClear: number[] = [];
for (let y = 0; y < BOARD_HEIGHT; y++) {
if (newBoard[y].every(cell => cell !== 0)) {
linesToClear.push(y);
}
}
// Show animation if lines were cleared
if (linesToClear.length > 0) {
setClearedLines(linesToClear);
setTimeout(() => setClearedLines([]), isMaxSpeed ? 0 : 300);
}
const newScore = prev.score + calculateScore(linesCleared, prev.level);
const newLines = prev.lines + linesCleared;
const newLevel = startLevel + Math.floor(newLines / 10);
if (clearedBoard[0].some(cell => cell !== 0)) {
return { ...prev, gameOver: true };
}
return {
...prev,
board: clearedBoard,
currentPiece: prev.nextPiece,
currentPosition: getStartPosition(),
nextPiece: getRandomTetromino(),
score: newScore,
level: newLevel,
lines: newLines,
};
});
}, [startLevel, isMaxSpeed]);
const drop = useCallback(() => {
setGameState((prev) => {
if (!prev.currentPiece || prev.gameOver || prev.isPaused) return prev;
const newPosition: Position = {
x: prev.currentPosition.x,
y: prev.currentPosition.y + 1,
};
if (!checkCollision(prev.board, prev.currentPiece, newPosition)) {
return { ...prev, currentPosition: newPosition };
}
return prev;
});
// Check if piece should lock after state update
setGameState((prev) => {
if (!prev.currentPiece) return prev;
const nextPosition: Position = {
x: prev.currentPosition.x,
y: prev.currentPosition.y + 1,
};
if (checkCollision(prev.board, prev.currentPiece, nextPosition)) {
// Piece has landed, lock it
const newBoard = mergePieceToBoard(prev.board, prev.currentPiece, prev.currentPosition);
const { newBoard: clearedBoard, linesCleared } = clearLines(newBoard);
// Find which lines were cleared for animation
const linesToClear: number[] = [];
for (let y = 0; y < BOARD_HEIGHT; y++) {
if (newBoard[y].every(cell => cell !== 0)) {
linesToClear.push(y);
}
}
// Show animation if lines were cleared
if (linesToClear.length > 0) {
setClearedLines(linesToClear);
setTimeout(() => setClearedLines([]), isMaxSpeed ? 0 : 300);
}
const newScore = prev.score + calculateScore(linesCleared, prev.level);
const newLines = prev.lines + linesCleared;
const newLevel = startLevel + Math.floor(newLines / 10);
if (clearedBoard[0].some(cell => cell !== 0)) {
return { ...prev, gameOver: true };
}
return {
...prev,
board: clearedBoard,
currentPiece: prev.nextPiece,
currentPosition: getStartPosition(),
nextPiece: getRandomTetromino(randomizerMode),
score: newScore,
level: newLevel,
lines: newLines,
};
}
return prev;
});
}, [startLevel, isMaxSpeed, randomizerMode]);
// Auto-start game on mount
useEffect(() => {
const timer = setTimeout(() => {
if (!gameStarted) {
startGame();
}
}, 1000);
return () => clearTimeout(timer);
}, [gameStarted, startGame]);
// Reset AI state when lines are cleared (board changes significantly)
useEffect(() => {
if (gameState.isAutoPlay && clearedLines.length > 0) {
aiMoveRef.current = null;
lastProcessedPieceRef.current = null;
aiStateRef.current = 'idle';
}
}, [clearedLines, gameState.isAutoPlay]);
// AI logic with state machine
useEffect(() => {
if (!gameState.isAutoPlay || gameState.gameOver || gameState.isPaused || !gameState.currentPiece) {
return;
}
// Watchdog: if AI has been in non-idle state for > 2 seconds, force reset
if (aiStateRef.current !== 'idle' && Date.now() - aiStateStartTimeRef.current > 2000) {
console.error('AI WATCHDOG: Stuck in state', aiStateRef.current, 'for > 2s, forcing reset');
hardDrop();
aiMoveRef.current = null;
lastProcessedPieceRef.current = null;
aiStateRef.current = 'idle';
return;
}
// State: IDLE - waiting for new piece
if (aiStateRef.current === 'idle' && gameState.currentPosition.y <= 1) {
const pieceId = `${gameState.currentPiece.type}-${gameState.currentPosition.x}`;
if (lastProcessedPieceRef.current !== pieceId) {
aiStateRef.current = 'calculating';
aiStateStartTimeRef.current = Date.now();
lastProcessedPieceRef.current = pieceId;
aiMoveRef.current = null;
const bestMove = findBestMove(gameState.board, gameState.currentPiece, aiWeights);
if (bestMove) {
aiMoveRef.current = {
...bestMove,
rotationsNeeded: bestMove.rotation,
rotationsDone: 0,
pieceType: gameState.currentPiece.type,
movesDone: 0,
};
setDebugInfo(bestMove.breakdown);
// Collect board features for neural network training
const boardFeatures = extractBoardFeatures(bestMove.breakdown);
boardFeaturesAccumulator.current.push(boardFeatures);
if (boardFeaturesAccumulator.current.length % 50 === 0) {
// console.log(`[Neural AI] Collected ${boardFeaturesAccumulator.current.length} board feature samples`);
}
aiStateRef.current = 'executing';
} else {
aiStateRef.current = 'dropping';
aiStateRef.current = 'dropping';
setTimeout(() => {
hardDrop();
aiStateRef.current = 'idle';
}, isMaxSpeed ? 0 : 50);
}
// Don't return here - let it fall through to executing state
}
}
// State: EXECUTING - rotating and moving piece
if (aiStateRef.current === 'executing' && aiMoveRef.current) {
const aiMove = aiMoveRef.current;
// Handle rotation
if (aiMove.rotationsDone < aiMove.rotationsNeeded) {
setTimeout(() => {
rotate();
if (aiMoveRef.current) {
aiMoveRef.current.rotationsDone++;
}
}, isMaxSpeed ? 0 : 20);
return;
}
// Handle horizontal movement
const targetX = aiMove.position.x;
const currentX = gameState.currentPosition.x;
if (currentX < targetX) {
aiMove.movesDone = (aiMove.movesDone || 0) + 1;
if (aiMove.movesDone > 20) {
aiStateRef.current = 'dropping';
setTimeout(() => {
hardDrop();
aiMoveRef.current = null;
lastProcessedPieceRef.current = null;
aiStateRef.current = 'idle';
}, isMaxSpeed ? 0 : 50);
return;
}
setTimeout(() => movePiece(1, 0), isMaxSpeed ? 0 : 5);
return;
} else if (currentX > targetX) {
aiMove.movesDone = (aiMove.movesDone || 0) + 1;
if (aiMove.movesDone > 20) {
aiStateRef.current = 'dropping';
setTimeout(() => {
hardDrop();
aiMoveRef.current = null;
lastProcessedPieceRef.current = null;
aiStateRef.current = 'idle';
}, isMaxSpeed ? 0 : 50);
return;
}
setTimeout(() => movePiece(-1, 0), isMaxSpeed ? 0 : 5);
return;
} else {
// At target position, transition to dropping
aiStateRef.current = 'dropping';
setTimeout(() => {
hardDrop();
aiMoveRef.current = null;
lastProcessedPieceRef.current = null; // Clear so AI can process next piece
aiStateRef.current = 'idle';
}, isMaxSpeed ? 0 : 50);
}
}
}, [gameState.isAutoPlay, gameState.currentPiece, gameState.currentPosition, gameState.gameOver, gameState.isPaused, gameState.board, aiWeights, movePiece, rotate, hardDrop, isMaxSpeed]);
// Store startGame in a ref to avoid dependency issues
const startGameRef = useRef(startGame);
useEffect(() => {
startGameRef.current = startGame;
}, [startGame]);
// Auto-restart after game over
useEffect(() => {
if (gameState.gameOver && gameState.isAutoPlay) {
const timer = setTimeout(() => {
startGameRef.current();
}, 2000);
return () => clearTimeout(timer);
}
}, [gameState.gameOver, gameState.isAutoPlay]);
// Game loop
useEffect(() => {
if (!gameStarted || gameState.gameOver || gameState.isPaused) return;
const speed = isMaxSpeed ? 0 : getDropSpeed(gameState.level);
const interval = setInterval(() => {
// Don't drop if AI is still rotating (horizontal movement is fast enough)
if (gameState.isAutoPlay && aiMoveRef.current) {
const aiMove = aiMoveRef.current;
const isRotating = aiMove.rotationsDone < aiMove.rotationsNeeded;
if (isRotating) {
return; // Skip this drop while rotating
}
}
drop();
}, speed);
return () => clearInterval(interval);
}, [gameStarted, gameState.gameOver, gameState.isPaused, gameState.level, gameState.isAutoPlay, drop, isMaxSpeed]);
// Keyboard controls
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ignore keyboard events when focused on input fields (weight tuning)
if (e.target instanceof HTMLInputElement) {
return;
}
if (!gameStarted) {
startGame();
return;
}
if (gameState.gameOver) return;
switch (e.key) {
case 'ArrowLeft':
e.preventDefault();
setGameState((prev) => ({ ...prev, isAutoPlay: false, level: prev.isAutoPlay ? 0 : prev.level }));
// Reset inactivity timer
if (inactivityTimerRef.current) clearTimeout(inactivityTimerRef.current);
inactivityTimerRef.current = window.setTimeout(() => {
setGameState((prev) => ({ ...prev, isAutoPlay: true, level: 10 }));
}, 10000);
movePiece(-1, 0);
break;
case 'ArrowRight':
e.preventDefault();
setGameState((prev) => ({ ...prev, isAutoPlay: false, level: prev.isAutoPlay ? 0 : prev.level }));
// Reset inactivity timer
if (inactivityTimerRef.current) clearTimeout(inactivityTimerRef.current);
inactivityTimerRef.current = window.setTimeout(() => {
setGameState((prev) => ({ ...prev, isAutoPlay: true, level: 10 }));
}, 10000);
movePiece(1, 0);
break;
case 'ArrowDown':
case 'ArrowUp':
e.preventDefault();
setGameState((prev) => ({ ...prev, isAutoPlay: false, level: prev.isAutoPlay ? 0 : prev.level }));
// Reset inactivity timer
if (inactivityTimerRef.current) clearTimeout(inactivityTimerRef.current);
inactivityTimerRef.current = window.setTimeout(() => {
setGameState((prev) => ({ ...prev, isAutoPlay: true, level: 10 }));
}, 10000);
rotate();
break;
case ' ':
e.preventDefault();
setGameState((prev) => ({ ...prev, isAutoPlay: false, level: prev.isAutoPlay ? 0 : prev.level }));
// Reset inactivity timer
if (inactivityTimerRef.current) clearTimeout(inactivityTimerRef.current);
inactivityTimerRef.current = window.setTimeout(() => {
setGameState((prev) => ({ ...prev, isAutoPlay: true, level: 10 }));
}, 10000);
hardDrop();
break;
case 'p':
case 'P':
e.preventDefault();
setGameState((prev) => ({ ...prev, isPaused: !prev.isPaused }));
break;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [gameStarted, gameState.gameOver, startGame, movePiece, drop, rotate]);
// Update neural network visualization
useEffect(() => {
if (aiMode === 'neural' && debugInfo) {
const input = extractBoardFeatures(debugInfo);
const output = weightsToOutput(aiWeights);
setCurrentNNInput(input);
setCurrentNNOutput(output);
}
}, [aiMode, debugInfo, aiWeights]);
const renderBoard = () => {
// Calculate ghost piece position
let ghostY = gameState.currentPosition.y;
if (gameState.currentPiece) {
while (!checkCollision(gameState.board, gameState.currentPiece, { x: gameState.currentPosition.x, y: ghostY + 1 })) {
ghostY++;
}
}
const displayBoard = gameState.currentPiece
? mergePieceToBoard(gameState.board, gameState.currentPiece, gameState.currentPosition)
: gameState.board;
return displayBoard.map((row, y) => (
<div key={y} className="flex">
{row.map((cell, x) => {
const isClearing = clearedLines.includes(y);
// Check if this cell is part of the ghost piece
let isGhost = false;
if (!gameState.isAutoPlay && gameState.currentPiece && ghostY !== gameState.currentPosition.y) {
for (let py = 0; py < gameState.currentPiece.shape.length; py++) {
for (let px = 0; px < gameState.currentPiece.shape[py].length; px++) {
if (gameState.currentPiece.shape[py][px]) {
const ghostX = gameState.currentPosition.x + px;
const ghostYPos = ghostY + py;
if (ghostX === x && ghostYPos === y && !cell) {
isGhost = true;
}
}
}
}
}
return (
<div
key={x}
className={`w-[30px] h-[30px] border border-gray-800 transition-all duration-200 ${isClearing ? 'animate-pulse' : ''}`}
style={{
backgroundColor: isClearing ? '#ffffff' : (isGhost ? 'transparent' : getColorForCell(cell)),
boxShadow: cell && !isClearing ? 'inset 0 0 10px rgba(0,0,0,0.3)' : (isGhost ? 'inset 0 0 0 2px rgba(255,255,255,0.3)' : 'none'),
}}
/>
);
})}
</div>
));
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 p-4">
<div className="max-w-7xl mx-auto">
{/* Tab Navigation */}
<div className="flex gap-2 mb-6 w-full max-w-5xl mx-auto">
<button
onClick={() => setActiveTab('game')}
className={`px-6 py-2 rounded-lg font-semibold transition-all ${activeTab === 'game'
? 'bg-gradient-to-r from-cyan-500 to-purple-500 text-white shadow-lg'
: 'bg-black/40 text-gray-400 hover:text-gray-200 border border-purple-500/20'
}`}
>
🎮 Game
</button>
<button
onClick={() => setActiveTab('manual')}
className={`px-6 py-2 rounded-lg font-semibold transition-all ${activeTab === 'manual'
? 'bg-gradient-to-r from-cyan-500 to-purple-500 text-white shadow-lg'
: 'bg-black/40 text-gray-400 hover:text-gray-200 border border-purple-500/20'
}`}
>
📚 Manual
</button>
<button
onClick={() => setActiveTab('log')}
className={`px-6 py-2 rounded-lg font-semibold transition-all ${activeTab === 'log'
? 'bg-gradient-to-r from-cyan-500 to-purple-500 text-white shadow-lg'
: 'bg-black/40 text-gray-400 hover:text-gray-200 border border-purple-500/20'
}`}
>
📝 Log
</button>
</div>
{/* Game Tab Content */}
{activeTab === 'game' && (
<div className="flex flex-col items-center w-full max-w-5xl mx-auto">
{/* Top Dashboard: Next Piece + Stats */}
<div className="flex flex-col md:flex-row gap-4 mb-6 w-full items-stretch justify-center">
<div className="flex-shrink-0 flex w-full md:w-auto">
<NextPieceDisplay nextPiece={gameState.nextPiece} />
</div>
<div className="flex-grow min-w-0 flex">
<StatsPanel
score={gameState.score}
lines={gameState.lines}
level={gameState.level}
debugInfo={debugInfo}
gameHistory={gameHistory}
gameCounter={gameCounter}
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-8 w-full mb-8 items-start">
{/* Left Column: Game Board + Controls */}
<div className="flex flex-col gap-4 w-full">
<div className="bg-black/40 backdrop-blur-sm p-6 rounded-2xl shadow-2xl border border-purple-500/20 flex flex-col w-full">
<div className="mb-4">
<h1 className="text-4xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 to-purple-400 mb-2">
Tetris
</h1>
{!gameStarted && (
<p className="text-gray-300 text-sm">Press any arrow key to start!</p>
)}
</div>
<div className="bg-black/60 p-2 rounded-lg self-center shadow-lg">
{renderBoard()}
</div>
{gameState.gameOver && (
<div className="mt-4 text-center">
<p className="text-2xl font-bold text-red-400 mb-2">Game Over!</p>
<Button onClick={startGame} className="bg-gradient-to-r from-cyan-500 to-purple-500 hover:from-cyan-600 hover:to-purple-600">
New Game
</Button>
</div>
)}
</div>
{/* Controls - Below Game Board */}
<div className="w-full">
<ControlsPanel
gameStarted={gameStarted}
gameOver={gameState.gameOver}
isAutoPlay={gameState.isAutoPlay}
isMaxSpeed={isMaxSpeed}
aiMode={aiMode}
startLevel={startLevel}
onAutoPlayChange={(checked) => {
setGameState((prev) => ({ ...prev, isAutoPlay: checked }));
aiMoveRef.current = null;
}}
onMaxSpeedChange={handleMaxSpeedChange}
onAiModeChange={setAiMode}
onStartLevelChange={(level) => {
setStartLevel(level);
localStorage.setItem('tetris-start-level', level.toString());
}}
onNewGame={startGame}
randomizerMode={randomizerMode}
onRandomizerModeChange={handleRandomizerModeChange}
/>
</div>
</div>
{/* Right Column: Weights (and other future panels) */}
<div className="flex flex-col gap-4 w-full">
{/* AI Weights Panel */}
{debugInfo && (
<AIWeightsPanel
debugInfo={debugInfo}
aiWeights={aiWeights}
gameHistory={gameHistory}
weightChanges={weightChanges}
gameCounter={gameCounter}
onWeightsChange={setAiWeights}
onHelpOpen={() => setIsHelpOpen(true)}
onResetAI={() => {
aiMoveRef.current = null;
lastProcessedPieceRef.current = null;
}}
adaptiveScaling={adaptiveScaling}
onAdaptiveScalingChange={(scaling) => {
setAdaptiveScaling(scaling);
localStorage.setItem('tetris-adaptive-scaling', JSON.stringify(scaling));
}}
/>
)}
{aiMode === 'neural' && <AIStrategyControl />}
</div>
</div>
{/* Weights History Chart - Full Width Row */}
{aiMode === 'neural' && (
<div className="mb-8 w-full">
<WeightsHistoryChart changes={weightChanges} />
</div>
)}
</div>
)}
{/* Help Popup */}
{
isHelpOpen && (
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center p-4 z-50">
<div className="bg-gradient-to-br from-slate-900 to-purple-900 border border-purple-500/30 rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-gradient-to-br from-slate-900 to-purple-900 border-b border-purple-500/30 p-6 flex items-center justify-between">
<h2 className="text-2xl font-bold text-cyan-400">AI Weights Guide</h2>
<button
onClick={() => setIsHelpOpen(false)}
className="text-gray-400 hover:text-cyan-400 transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
<div className="p-6 space-y-4 text-gray-300">
<p className="text-sm text-gray-400 mb-4">
These weights control how the AI evaluates each possible move. Higher values make the AI prioritize (for bonuses) or avoid (for penalties) that factor more strongly.
</p>
<div className="space-y-3">
<div className="bg-black/30 p-4 rounded-lg border border-green-500/20">
<h3 className="font-bold text-green-400 mb-2">+ Lines Cleared (Bonus)</h3>
<p className="text-sm">Rewards clearing lines. Higher values make the AI prioritize moves that clear multiple lines at once (Tetris = 4 lines).</p>
</div>
<div className="bg-black/30 p-4 rounded-lg border border-blue-500/20">
<h3 className="font-bold text-blue-400 mb-2">+ Contact (Bonus)</h3>
<p className="text-sm">Rewards placing pieces adjacent to existing blocks. Encourages compact, connected structures rather than isolated pieces.</p>
</div>
<div className="bg-black/30 p-4 rounded-lg border border-red-600/20">
<h3 className="font-bold text-red-600 mb-2">- Holes Created (Penalty)</h3>
<p className="text-sm">Penalty for moves that create NEW holes. Holes are empty cells with blocks above them, making them hard to fill and leading to game over.</p>
</div>
<div className="bg-black/30 p-4 rounded-lg border border-pink-600/20">
<h3 className="font-bold text-pink-600 mb-2">- Overhangs Created (Penalty)</h3>
<p className="text-sm">Extra penalty for creating NEW overhangs. Prevents the AI from making moves that create problematic structures.</p>
</div>
<div className="bg-black/30 p-4 rounded-lg border border-emerald-500/20">
<h3 className="font-bold text-emerald-400 mb-2">+ Overhangs Filled (Bonus)</h3>
<p className="text-sm">Rewards filling in existing overhangs. Encourages the AI to fix problematic structures when possible.</p>
</div>
<div className="bg-black/30 p-4 rounded-lg border border-amber-500/20">
<h3 className="font-bold text-amber-400 mb-2">- Height Added (Penalty)</h3>
<p className="text-sm">Penalizes moves that increase the stack height. Keeps the board low to avoid game over and maintain flexibility.</p>
</div>
<div className="bg-black/30 p-4 rounded-lg border border-rose-600/20">
<h3 className="font-bold text-rose-500 mb-2">- Well Depth² (Penalty)</h3>
<p className="text-sm">Squared penalty for well depth. Deep vertical gaps (wells) limit placement options. A 4-deep well is penalized 16x more than a 1-deep well.</p>
</div>
<div className="bg-black/30 p-4 rounded-lg border border-violet-500/20">
<h3 className="font-bold text-violet-400 mb-2">- Bumpiness (Penalty)</h3>
<p className="text-sm">Penalizes uneven column heights. Smooth, flat surfaces are easier to work with and reduce the chance of creating holes.</p>
</div>
<div className="bg-black/30 p-4 rounded-lg border border-yellow-500/20">
<h3 className="font-bold text-yellow-400 mb-2">- Avg Height (Penalty)</h3>
<p className="text-sm">Penalizes the average height across all columns. Encourages keeping the overall board low and clearing lines regularly.</p>
</div>
</div>
<div className="mt-6 p-4 bg-purple-500/10 border border-purple-500/30 rounded-lg">
<h3 className="font-bold text-purple-400 mb-2">💡 Tuning Tips</h3>
<ul className="text-sm space-y-1 list-disc list-inside">
<li>Start with default values and adjust one weight at a time</li>
<li>Use arrow keys () to fine-tune values while watching the AI play</li>
<li>Higher penalty values make the AI avoid that factor more aggressively</li>
<li>Balance is key - extreme values can cause unexpected behavior</li>
</ul>
</div>
</div>
</div>
</div>
)
}
{/* Neural Network Visualization - Full Width Below */}
{
aiMode === 'neural' && (
<div className="mt-8 max-w-5xl mx-auto w-full">
<div className="flex flex-col gap-8 w-full">
<div className="w-full">
<NeuralNetworkVisualizer
network={neuralNetwork}
currentInput={currentNNInput}
currentOutput={currentNNOutput}
/>
</div>
<div className="w-full">
<PerformanceChart history={gameHistory} />
</div>
</div>
</div>
)
}
{/* Manual Tab Content */}
{activeTab === 'manual' && (
<div className="bg-black/40 backdrop-blur-sm p-8 rounded-2xl shadow-2xl border border-purple-500/20 max-w-4xl mx-auto">
<MarkdownRenderer
content={manualContent || '# Loading...'}
className="prose-invert"
/>
</div>
)}
{/* Log Tab Content */}
{activeTab === 'log' && (
<div className="max-w-4xl mx-auto mb-8">
<LearningLog changes={weightChanges} />
</div>
)}
</div>
</div >
);
};
export default Tetris;