play time | basic shit

This commit is contained in:
lovebird 2026-02-06 21:23:24 +01:00
parent 34fe0f1690
commit fff4359515
16 changed files with 2897 additions and 31 deletions

View File

@ -48,6 +48,7 @@ const PlaygroundImageEditor = React.lazy(() => import("./pages/PlaygroundImageEd
const VideoGenPlayground = React.lazy(() => import("./pages/VideoGenPlayground"));
const PlaygroundCanvas = React.lazy(() => import("./pages/PlaygroundCanvas"));
const TypesPlayground = React.lazy(() => import("./components/types/TypesPlayground"));
const Tetris = React.lazy(() => import("./apps/tetris/Tetris"));
import LogsPage from "./components/logging/LogsPage";
const queryClient = new QueryClient();
@ -143,6 +144,9 @@ const AppWrapper = () => {
{/* Logs */}
<Route path="/logs" element={<LogsPage />} />
{/* Apps */}
<Route path="/app/tetris" element={<React.Suspense fallback={<div>Loading...</div>}><Tetris /></React.Suspense>} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
<Route path="*" element={<React.Suspense fallback={<div>Loading...</div>}><NotFound /></React.Suspense>} />
</Routes >

View File

@ -0,0 +1,95 @@
import React from 'react';
import { AIWeights } from './aiPlayer';
interface WeightChange {
timestamp: number;
gameNumber: number;
score: number;
lines: number;
oldWeights: AIWeights;
newWeights: AIWeights;
}
interface LearningLogProps {
changes: WeightChange[];
}
export const LearningLog: React.FC<LearningLogProps> = ({ changes }) => {
if (changes.length === 0) {
return (
<div className="bg-black/40 backdrop-blur-sm p-6 rounded-2xl shadow-2xl border border-purple-500/20">
<h3 className="text-lg font-bold text-cyan-400 mb-3">Learning Log</h3>
<p className="text-gray-400 text-sm">No weight changes yet. Play some games in Neural mode!</p>
</div>
);
}
const calculateWeightDelta = (oldVal: number, newVal: number): { delta: number; percent: number } => {
const delta = newVal - oldVal;
const percent = oldVal !== 0 ? (delta / oldVal) * 100 : 0;
return { delta, percent };
};
const getSignificantChanges = (change: WeightChange) => {
const keys = Object.keys(change.oldWeights) as (keyof AIWeights)[];
const significant = keys
.map(key => {
const { delta, percent } = calculateWeightDelta(change.oldWeights[key], change.newWeights[key]);
return { key, delta, percent, newValue: change.newWeights[key] };
})
.filter(item => Math.abs(item.percent) > 5) // Only show changes > 5%
.sort((a, b) => Math.abs(b.percent) - Math.abs(a.percent))
.slice(0, 3); // Top 3 changes
return significant;
};
return (
<div className="bg-black/40 backdrop-blur-sm p-6 rounded-2xl shadow-2xl border border-purple-500/20">
<h3 className="text-lg font-bold text-cyan-400 mb-4">Learning Log</h3>
<div className="space-y-3 max-h-96 overflow-y-auto">
{changes.slice().reverse().map((change, idx) => {
const significantChanges = getSignificantChanges(change);
const timeAgo = new Date(change.timestamp).toLocaleTimeString();
return (
<div
key={idx}
className="bg-black/30 p-4 rounded-lg border border-purple-500/10 hover:border-purple-500/30 transition-colors"
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="text-purple-400 font-bold">Game #{change.gameNumber}</span>
<span className="text-gray-500 text-xs">{timeAgo}</span>
</div>
<div className="text-xs text-gray-400">
Score: <span className="text-cyan-400 font-semibold">{change.score}</span> |
Lines: <span className="text-green-400 font-semibold">{change.lines}</span>
</div>
</div>
{significantChanges.length > 0 ? (
<div className="space-y-1">
<p className="text-xs text-gray-500 mb-1">Top weight changes:</p>
{significantChanges.map((item, i) => (
<div key={i} className="flex items-center justify-between text-xs">
<span className="text-gray-300">{item.key}:</span>
<div className="flex items-center gap-2">
<span className="text-gray-400">{item.newValue.toFixed(0)}</span>
<span className={`font-semibold ${item.percent > 0 ? 'text-green-400' : 'text-red-400'}`}>
{item.percent > 0 ? '+' : ''}{item.percent.toFixed(1)}%
</span>
</div>
</div>
))}
</div>
) : (
<p className="text-xs text-gray-500">Minor adjustments (&lt;5% change)</p>
)}
</div>
);
})}
</div>
</div>
);
};

View File

@ -0,0 +1,289 @@
import React, { useRef, useEffect } from 'react';
import { NeuralNetwork } from './neuralNetwork';
interface NeuralNetworkVisualizerProps {
network: NeuralNetwork;
currentInput?: number[];
currentOutput?: number[];
}
export const NeuralNetworkVisualizer: React.FC<NeuralNetworkVisualizerProps> = ({
network,
currentInput,
currentOutput,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [hoveredNeuron, setHoveredNeuron] = React.useState<{
layer: number;
index: number;
x: number;
y: number;
name: string;
value: number;
} | null>(null);
// Neuron names for input and output layers
const inputNames = [
'Lines Cleared', 'Contact', 'Holes', 'Holes Created',
'Overhangs', 'Overhangs Created', 'Overhangs Filled',
'Height Added', 'Wells', 'Well Depth²',
'Bumpiness', 'Max Height', 'Avg Height'
];
const outputNames = [
'lineCleared', 'contact', 'holes', 'holesCreated',
'overhangs', 'overhangsCreated', 'overhangsFilled',
'heightAdded', 'wells', 'wellDepthSquared',
'bumpiness', 'maxHeight', 'avgHeight'
];
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Set canvas size
const width = canvas.width;
const height = canvas.height;
// Clear canvas
ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, width, height);
// Get layer sizes
const layerSizes = [
network.config.inputSize,
...network.config.hiddenLayers,
network.config.outputSize,
];
const numLayers = layerSizes.length;
const layerSpacing = width / (numLayers + 1);
const maxNeurons = Math.max(...layerSizes);
// Calculate neuron positions
const neuronPositions: { x: number; y: number }[][] = [];
layerSizes.forEach((size, layerIdx) => {
const positions: { x: number; y: number }[] = [];
const x = layerSpacing * (layerIdx + 1);
const neuronSpacing = height / (size + 1);
for (let i = 0; i < size; i++) {
const y = neuronSpacing * (i + 1);
positions.push({ x, y });
}
neuronPositions.push(positions);
});
// Draw connections (weights)
for (let layer = 0; layer < numLayers - 1; layer++) {
const fromLayer = neuronPositions[layer];
const toLayer = neuronPositions[layer + 1];
const weights = network.weights[layer];
for (let i = 0; i < toLayer.length; i++) {
for (let j = 0; j < fromLayer.length; j++) {
const weight = weights.data[i][j];
const from = fromLayer[j];
const to = toLayer[i];
// Color based on weight value (red = negative, blue = positive)
const absWeight = Math.abs(weight);
const alpha = Math.min(absWeight * 0.5, 0.8);
const color = weight > 0
? `rgba(59, 130, 246, ${alpha})` // Blue for positive
: `rgba(239, 68, 68, ${alpha})`; // Red for negative
ctx.strokeStyle = color;
ctx.lineWidth = Math.min(absWeight * 2, 3);
ctx.beginPath();
ctx.moveTo(from.x, from.y);
ctx.lineTo(to.x, to.y);
ctx.stroke();
}
}
}
// Draw neurons
layerSizes.forEach((size, layerIdx) => {
const positions = neuronPositions[layerIdx];
positions.forEach((pos, neuronIdx) => {
// Get activation value if available
let activation = 0.5; // Default neutral
if (layerIdx === 0 && currentInput) {
activation = currentInput[neuronIdx] || 0;
} else if (layerIdx === numLayers - 1 && currentOutput) {
activation = currentOutput[neuronIdx] || 0;
}
// Draw neuron circle
const radius = 8;
// Glow effect based on activation
const gradient = ctx.createRadialGradient(pos.x, pos.y, 0, pos.x, pos.y, radius * 2);
const glowColor = activation > 0.5
? `rgba(34, 197, 94, ${activation})` // Green for high activation
: `rgba(148, 163, 184, ${activation * 0.5})`; // Gray for low activation
gradient.addColorStop(0, glowColor);
gradient.addColorStop(1, 'rgba(0, 0, 0, 0)');
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc(pos.x, pos.y, radius * 2, 0, Math.PI * 2);
ctx.fill();
// Draw neuron body
ctx.fillStyle = activation > 0.5
? `rgba(34, 197, 94, ${0.3 + activation * 0.7})`
: `rgba(100, 116, 139, ${0.3 + activation * 0.4})`;
ctx.beginPath();
ctx.arc(pos.x, pos.y, radius, 0, Math.PI * 2);
ctx.fill();
// Draw neuron border
ctx.strokeStyle = activation > 0.5
? '#22c55e'
: '#64748b';
ctx.lineWidth = 2;
ctx.stroke();
});
});
// Draw layer labels
ctx.fillStyle = '#94a3b8';
ctx.font = '12px monospace';
ctx.textAlign = 'center';
const labels = ['Input', ...network.config.hiddenLayers.map((_, i) => `Hidden ${i + 1}`), 'Output'];
labels.forEach((label, idx) => {
const x = layerSpacing * (idx + 1);
ctx.fillText(label, x, 20);
ctx.fillText(`(${layerSizes[idx]})`, x, 35);
});
// Draw legend
ctx.textAlign = 'left';
ctx.font = '11px monospace';
// Positive weights
ctx.fillStyle = 'rgba(59, 130, 246, 0.8)';
ctx.fillRect(10, height - 60, 15, 3);
ctx.fillStyle = '#94a3b8';
ctx.fillText('Positive weight', 30, height - 55);
// Negative weights
ctx.fillStyle = 'rgba(239, 68, 68, 0.8)';
ctx.fillRect(10, height - 40, 15, 3);
ctx.fillStyle = '#94a3b8';
ctx.fillText('Negative weight', 30, height - 35);
// Active neuron
ctx.fillStyle = 'rgba(34, 197, 94, 0.8)';
ctx.beginPath();
ctx.arc(17, height - 20, 6, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#94a3b8';
ctx.fillText('Active neuron', 30, height - 15);
}, [network, currentInput, currentOutput]);
// Handle mouse move for hover detection
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const mouseX = (e.clientX - rect.left) * scaleX;
const mouseY = (e.clientY - rect.top) * scaleY;
// Calculate neuron positions (same logic as drawing)
const layerSizes = [
network.config.inputSize,
...network.config.hiddenLayers,
network.config.outputSize,
];
const numLayers = layerSizes.length;
const layerSpacing = canvas.width / (numLayers + 1);
let found = false;
layerSizes.forEach((size, layerIdx) => {
const x = layerSpacing * (layerIdx + 1);
const neuronSpacing = canvas.height / (size + 1);
for (let i = 0; i < size; i++) {
const y = neuronSpacing * (i + 1);
const distance = Math.sqrt((mouseX - x) ** 2 + (mouseY - y) ** 2);
if (distance < 12) { // Hover radius
// Only show tooltips for input and output layers
if (layerIdx === 0 || layerIdx === numLayers - 1) {
const isInput = layerIdx === 0;
const name = isInput ? inputNames[i] : outputNames[i];
const value = isInput
? (currentInput?.[i] ?? 0)
: (currentOutput?.[i] ?? 0);
setHoveredNeuron({
layer: layerIdx,
index: i,
x: e.clientX - rect.left,
y: e.clientY - rect.top,
name,
value,
});
found = true;
}
}
}
});
if (!found) {
setHoveredNeuron(null);
}
};
return (
<div ref={containerRef} className="bg-black/40 backdrop-blur-sm p-4 rounded-2xl shadow-2xl border border-purple-500/20 relative">
<h3 className="text-lg font-bold text-cyan-400 mb-3">Neural Network Visualization</h3>
<div className="relative">
<canvas
ref={canvasRef}
width={800}
height={500}
className="w-full h-auto rounded-lg border border-gray-700 cursor-crosshair"
onMouseMove={handleMouseMove}
onMouseLeave={() => setHoveredNeuron(null)}
/>
{/* Hover Tooltip */}
{hoveredNeuron && (
<div
className="absolute bg-black/90 border border-cyan-400/50 rounded-lg px-3 py-2 pointer-events-none z-10"
style={{
left: `${hoveredNeuron.x + 15}px`,
top: `${hoveredNeuron.y - 10}px`,
}}
>
<div className="text-xs font-bold text-cyan-400">{hoveredNeuron.name}</div>
<div className="text-xs text-gray-300">
Value: <span className="text-green-400 font-semibold">{hoveredNeuron.value.toFixed(3)}</span>
</div>
</div>
)}
</div>
<div className="mt-3 text-xs text-gray-400 space-y-1">
<p> Network learns optimal weights from game performance</p>
<p> Brighter neurons = higher activation</p>
<p> Line thickness = weight strength</p>
<p> <span className="text-cyan-400">Hover over neurons</span> to see names and values</p>
</div>
</div>
);
};

View File

@ -0,0 +1,153 @@
import React from 'react';
interface GameResult {
score: number;
lines: number;
level: number;
timestamp: number;
}
interface PerformanceChartProps {
history: GameResult[];
}
export const PerformanceChart: React.FC<PerformanceChartProps> = ({ history }) => {
if (history.length === 0) {
return (
<div className="bg-black/40 backdrop-blur-sm p-6 rounded-2xl shadow-2xl border border-purple-500/20">
<h3 className="text-lg font-bold text-cyan-400 mb-3">Performance Trend</h3>
<p className="text-gray-400 text-sm">No games played yet. Start playing to see improvement!</p>
</div>
);
}
// Take last 20 games for visualization
const recentGames = history.slice(-20);
const maxScore = Math.max(...recentGames.map(g => g.score), 100);
const maxLevel = Math.max(...recentGames.map(g => g.level), 5);
// Calculate statistics
const avgScore = recentGames.reduce((sum, g) => sum + g.score, 0) / recentGames.length;
const avgLevel = recentGames.reduce((sum, g) => sum + g.level, 0) / recentGames.length;
const bestGame = recentGames.reduce((best, game) =>
game.score > best.score ? game : best
, recentGames[0]);
// Calculate trend (simple moving average)
const trendWindow = 5;
const trends = recentGames.map((_, idx) => {
const start = Math.max(0, idx - trendWindow + 1);
const window = recentGames.slice(start, idx + 1);
return window.reduce((sum, g) => sum + g.score, 0) / window.length;
});
const isImproving = trends.length > 1 && trends[trends.length - 1] > trends[0];
return (
<div className="bg-black/40 backdrop-blur-sm p-6 rounded-2xl shadow-2xl border border-purple-500/20">
<h3 className="text-lg font-bold text-cyan-400 mb-4">Performance Trend</h3>
{/* Stats Summary */}
<div className="grid grid-cols-3 gap-3 mb-4">
<div className="bg-black/30 p-3 rounded-lg border border-purple-500/10">
<div className="text-xs text-gray-400">Avg Score</div>
<div className="text-xl font-bold text-purple-400">{Math.round(avgScore)}</div>
</div>
<div className="bg-black/30 p-3 rounded-lg border border-purple-500/10">
<div className="text-xs text-gray-400">Best Score</div>
<div className="text-xl font-bold text-green-400">{bestGame.score}</div>
</div>
<div className="bg-black/30 p-3 rounded-lg border border-purple-500/10">
<div className="text-xs text-gray-400">Avg Level</div>
<div className="text-xl font-bold text-cyan-400">{avgLevel.toFixed(1)}</div>
</div>
</div>
{/* Trend Indicator */}
<div className="mb-4 flex items-center gap-2">
<span className="text-sm text-gray-400">Trend:</span>
<span className={`text-sm font-semibold ${isImproving ? 'text-green-400' : 'text-yellow-400'}`}>
{isImproving ? '📈 Improving' : '📊 Stable'}
</span>
</div>
{/* Chart */}
<div className="relative h-48 bg-black/30 rounded-lg p-4 border border-gray-700">
{/* Y-axis labels */}
<div className="absolute left-0 top-0 bottom-0 w-12 flex flex-col justify-between text-xs text-gray-500">
<span>{maxScore >= 1000 ? `${(maxScore / 1000).toFixed(0)}k` : maxScore}</span>
<span>{maxScore >= 2000 ? `${(maxScore / 2000).toFixed(0)}k` : Math.round(maxScore / 2)}</span>
<span>0</span>
</div>
{/* Chart area */}
<div className="ml-12 h-full relative">
{/* Grid lines */}
<div className="absolute inset-0 flex flex-col justify-between">
{[0, 1, 2, 3, 4].map(i => (
<div key={i} className="border-t border-gray-800" />
))}
</div>
{/* Score bars */}
<div className="absolute inset-0 flex items-end justify-around gap-1">
{recentGames.map((game, idx) => {
const heightPercent = Math.max((game.score / maxScore) * 100, 2); // Minimum 2% height
const trendHeight = (trends[idx] / maxScore) * 100;
return (
<div key={idx} className="flex-1 flex flex-col items-center justify-end group relative min-w-0">
{/* Trend line marker */}
{trendHeight > 0 && (
<div
className="absolute w-full border-t-2 border-yellow-400/50"
style={{ bottom: `${trendHeight}%` }}
/>
)}
{/* Score bar */}
<div
className={`w-full rounded-t transition-all ${game.score === bestGame.score
? 'bg-gradient-to-t from-green-500 to-green-400'
: 'bg-gradient-to-t from-cyan-500 to-purple-500'
} hover:opacity-80 cursor-pointer`}
style={{ height: `${heightPercent}%`, minHeight: '4px' }}
>
{/* Tooltip on hover */}
<div className="opacity-0 group-hover:opacity-100 absolute bottom-full mb-2 left-1/2 -translate-x-1/2 bg-black/90 border border-cyan-400/50 rounded px-2 py-1 whitespace-nowrap text-xs pointer-events-none z-10">
<div className="font-bold text-cyan-400">Game #{history.length - recentGames.length + idx + 1}</div>
<div className="text-gray-300">Score: <span className="text-purple-400 font-bold">{game.score}</span></div>
<div className="text-gray-300">Level: <span className="text-green-400">{game.level}</span></div>
<div className="text-gray-300">Lines: <span className="text-cyan-400">{game.lines}</span></div>
</div>
</div>
</div>
);
})}
</div>
</div>
{/* X-axis label */}
<div className="absolute bottom-0 left-0 right-0 text-center text-xs text-gray-500 mt-2">
Last {recentGames.length} Games
</div>
</div>
{/* Legend */}
<div className="mt-4 flex items-center gap-4 text-xs">
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-gradient-to-t from-cyan-500 to-purple-500 rounded" />
<span className="text-gray-400">Score</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-0.5 bg-yellow-400/50" />
<span className="text-gray-400">Trend (avg)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-gradient-to-t from-green-500 to-green-400 rounded" />
<span className="text-gray-400">Best score</span>
</div>
</div>
</div>
);
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,177 @@
import React, { useState, useEffect, useRef } from 'react';
import { AIWeights, DEFAULT_WEIGHTS } from './aiPlayer';
import { Button } from '@/components/ui/button';
import { X, RotateCcw } from 'lucide-react';
const STORAGE_KEY = 'tetris-ai-weights';
interface WeightsTunerProps {
isOpen: boolean;
onClose: () => void;
onWeightsChange: (weights: AIWeights) => void;
}
export const WeightsTuner: React.FC<WeightsTunerProps> = ({ isOpen, onClose, onWeightsChange }) => {
const [weights, setWeights] = useState<AIWeights>(() => {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : DEFAULT_WEIGHTS;
});
const popupRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isOpen) {
// Focus first input when opened
const firstInput = popupRef.current?.querySelector('input');
firstInput?.focus();
}
}, [isOpen]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
const updateWeight = (key: keyof AIWeights, value: number) => {
const newWeights = { ...weights, [key]: value };
setWeights(newWeights);
localStorage.setItem(STORAGE_KEY, JSON.stringify(newWeights));
onWeightsChange(newWeights);
};
const resetToDefaults = () => {
setWeights(DEFAULT_WEIGHTS);
localStorage.setItem(STORAGE_KEY, JSON.stringify(DEFAULT_WEIGHTS));
onWeightsChange(DEFAULT_WEIGHTS);
};
if (!isOpen) return null;
const weightFields: { key: keyof AIWeights; label: string; step: number }[] = [
{ key: 'lineCleared', label: 'Lines Cleared', step: 100 },
{ key: 'contact', label: 'Contact', step: 10 },
{ key: 'holes', label: 'Holes (penalty)', step: 10 },
{ key: 'holesCreated', label: 'Holes Created (penalty)', step: 10 },
{ key: 'overhangs', label: 'Overhangs (penalty)', step: 10 },
{ key: 'overhangsCreated', label: 'Overhangs Created (penalty)', step: 10 },
{ key: 'overhangsFilled', label: 'Overhangs Filled (bonus)', step: 10 },
{ key: 'heightAdded', label: 'Height Added (penalty)', step: 10 },
{ key: 'wells', label: 'Wells (penalty)', step: 10 },
{ key: 'wellDepthSquared', label: 'Well Depth² (penalty)', step: 10 },
{ key: 'bumpiness', label: 'Bumpiness (penalty)', step: 5 },
{ key: 'maxHeight', label: 'Max Height (penalty)', step: 5 },
{ key: 'avgHeight', label: 'Avg Height (penalty)', step: 5 },
];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center pointer-events-none">
<div
ref={popupRef}
className="bg-black/95 backdrop-blur-md border-2 border-purple-500/50 rounded-2xl shadow-2xl p-6 max-w-2xl w-full max-h-[80vh] overflow-y-auto pointer-events-auto"
style={{
boxShadow: '0 0 40px rgba(168, 85, 247, 0.4)',
}}
>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 to-purple-400">
AI Weights Tuner
</h2>
<div className="flex gap-2">
<Button
onClick={resetToDefaults}
variant="outline"
size="sm"
className="border-purple-500/50 hover:bg-purple-500/20"
>
<RotateCcw className="w-4 h-4 mr-2" />
Reset
</Button>
<Button
onClick={onClose}
variant="outline"
size="sm"
className="border-purple-500/50 hover:bg-purple-500/20"
>
<X className="w-4 h-4" />
</Button>
</div>
</div>
{/* Instructions */}
<div className="mb-6 p-4 bg-purple-500/10 border border-purple-500/30 rounded-lg">
<p className="text-sm text-gray-300">
<kbd className="px-2 py-1 bg-gray-800 rounded text-xs mr-2"></kbd>
Adjust values
<kbd className="px-2 py-1 bg-gray-800 rounded text-xs mx-2">Tab</kbd>
Navigate fields
<kbd className="px-2 py-1 bg-gray-800 rounded text-xs mx-2">Esc</kbd>
Close
</p>
</div>
{/* Weight Fields */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{weightFields.map(({ key, label, step }) => (
<div key={key} className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-300">
{label}
</label>
<input
type="number"
value={weights[key]}
onChange={(e) => updateWeight(key, parseFloat(e.target.value) || 0)}
step={step}
className="bg-gray-900/50 border border-purple-500/30 rounded-lg px-3 py-2 text-white focus:outline-none focus:border-purple-500 focus:ring-2 focus:ring-purple-500/20"
onKeyDown={(e) => {
if (e.key === 'ArrowUp') {
e.preventDefault();
updateWeight(key, weights[key] + step);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
updateWeight(key, weights[key] - step);
}
}}
/>
</div>
))}
</div>
{/* Footer */}
<div className="mt-6 pt-4 border-t border-purple-500/20">
<p className="text-xs text-gray-400 text-center">
Weights are automatically saved to localStorage
</p>
</div>
</div>
</div>
);
};
// Hook to load weights from localStorage
export const useAIWeights = (): AIWeights => {
const [weights, setWeights] = useState<AIWeights>(() => {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : DEFAULT_WEIGHTS;
});
useEffect(() => {
const handleStorageChange = () => {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
setWeights(JSON.parse(stored));
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, []);
return weights;
};

View File

@ -0,0 +1,328 @@
import { Tetromino, Position, Move, BOARD_WIDTH, BOARD_HEIGHT } from './types';
import { checkCollision, mergePieceToBoard, rotateTetromino, clearLines } from './gameLogic';
export interface AIWeights {
lineCleared: number;
contact: number;
holes: number;
holesCreated: number;
overhangs: number;
overhangsCreated: number;
overhangsFilled: number;
heightAdded: number;
wells: number;
wellDepthSquared: number;
bumpiness: number;
maxHeight: number;
avgHeight: number;
}
export const DEFAULT_WEIGHTS: AIWeights = {
lineCleared: 10000,
contact: 100,
holes: 500,
holesCreated: 800,
overhangs: 500,
overhangsCreated: 1000,
overhangsFilled: 200,
heightAdded: 800,
wells: 100,
wellDepthSquared: 100,
bumpiness: 50,
maxHeight: 50,
avgHeight: 20,
};
function countContacts(board: number[][], piece: Tetromino, position: Position): number {
let contacts = 0;
for (let y = 0; y < piece.shape.length; y++) {
for (let x = 0; x < piece.shape[y].length; x++) {
if (piece.shape[y][x]) {
const boardY = position.y + y;
const boardX = position.x + x;
// Check below
if (boardY + 1 >= BOARD_HEIGHT || (boardY + 1 >= 0 && board[boardY + 1]?.[boardX])) {
contacts++;
}
// Check left
if (boardX - 1 < 0 || (boardY >= 0 && board[boardY]?.[boardX - 1])) {
contacts++;
}
// Check right
if (boardX + 1 >= BOARD_WIDTH || (boardY >= 0 && board[boardY]?.[boardX + 1])) {
contacts++;
}
}
}
}
return contacts;
}
function countCompletedLines(board: number[][]): number {
let completedLines = 0;
for (let y = 0; y < BOARD_HEIGHT; y++) {
if (board[y].every(cell => cell !== 0)) {
completedLines++;
}
}
return completedLines;
}
function countHoles(board: number[][]): number {
let holes = 0;
for (let x = 0; x < BOARD_WIDTH; x++) {
let blockFound = false;
for (let y = 0; y < BOARD_HEIGHT; y++) {
if (board[y][x] !== 0) {
blockFound = true;
} else if (blockFound) {
holes++;
}
}
}
return holes;
}
function getColumnHeights(board: number[][]): number[] {
const heights: number[] = [];
for (let x = 0; x < BOARD_WIDTH; x++) {
let height = 0;
for (let y = 0; y < BOARD_HEIGHT; y++) {
if (board[y][x] !== 0) {
height = BOARD_HEIGHT - y;
break;
}
}
heights.push(height);
}
return heights;
}
function countWells(board: number[][]): { wells: number; wellDepth: number } {
const heights = getColumnHeights(board);
let totalWells = 0;
let maxWellDepth = 0;
for (let x = 0; x < BOARD_WIDTH; x++) {
const currentHeight = heights[x];
const leftHeight = x > 0 ? heights[x - 1] : 0;
const rightHeight = x < BOARD_WIDTH - 1 ? heights[x + 1] : 0;
// A well is when both neighbors are higher
const wellDepth = Math.min(leftHeight, rightHeight) - currentHeight;
if (wellDepth > 0) {
totalWells++;
maxWellDepth = Math.max(maxWellDepth, wellDepth);
}
}
return { wells: totalWells, wellDepth: maxWellDepth };
}
function countBumpiness(board: number[][]): number {
const heights = getColumnHeights(board);
let bumpiness = 0;
for (let x = 0; x < BOARD_WIDTH - 1; x++) {
bumpiness += Math.abs(heights[x] - heights[x + 1]);
}
return bumpiness;
}
function countOverhangs(board: number[][]): number {
let overhangs = 0;
for (let y = 0; y < BOARD_HEIGHT - 1; y++) {
for (let x = 0; x < BOARD_WIDTH; x++) {
// If there's a block with empty space below it
if (board[y][x] !== 0 && board[y + 1][x] === 0) {
// Check if there's a block further below (overhang)
for (let checkY = y + 2; checkY < BOARD_HEIGHT; checkY++) {
if (board[checkY][x] !== 0) {
overhangs++;
break;
}
}
}
}
}
return overhangs;
}
function countOverhangsFilled(boardBefore: number[][], boardAfter: number[][], piece: Tetromino, position: Position): number {
let filled = 0;
// Check each cell of the placed piece
for (let y = 0; y < piece.shape.length; y++) {
for (let x = 0; x < piece.shape[y].length; x++) {
if (piece.shape[y][x]) {
const boardY = position.y + y;
const boardX = position.x + x;
if (boardY >= 0 && boardY < BOARD_HEIGHT && boardX >= 0 && boardX < BOARD_WIDTH) {
// Check if there's a block above this position (we're filling under it)
for (let checkY = boardY - 1; checkY >= 0; checkY--) {
if (boardBefore[checkY][boardX] !== 0) {
filled++;
break;
}
}
}
}
}
}
return filled;
}
function evaluatePosition(board: number[][], piece: Tetromino, position: Position, weights: AIWeights): { score: number; breakdown: any } {
// Simulate placing the piece
const testBoard = mergePieceToBoard(board, piece, position);
const { newBoard, linesCleared } = clearLines(testBoard);
// Count metrics
const contacts = countContacts(board, piece, position);
const holesBefore = countHoles(board);
const holesAfter = countHoles(newBoard);
const holesCreated = Math.max(0, holesAfter - holesBefore);
const overhangsBefore = countOverhangs(board);
const overhangsAfter = countOverhangs(newBoard);
const overhangsCreated = Math.max(0, overhangsAfter - overhangsBefore);
const overhangsFilled = countOverhangsFilled(board, testBoard, piece, position);
const heightsBefore = getColumnHeights(board);
const heightsAfter = getColumnHeights(newBoard);
const maxHeightBefore = Math.max(...heightsBefore);
const maxHeightAfter = Math.max(...heightsAfter);
const heightAdded = Math.max(0, maxHeightAfter - maxHeightBefore);
const { wells, wellDepth } = countWells(newBoard);
const bumpiness = countBumpiness(newBoard);
const avgHeight = heightsAfter.reduce((a, b) => a + b, 0) / heightsAfter.length;
// Calculate score components using configurable weights
const lineScore = linesCleared * weights.lineCleared;
const contactScore = contacts * weights.contact;
const holesPenalty = holesAfter * weights.holes;
const holesCreatedPenalty = holesCreated * weights.holesCreated;
const overhangPenalty = overhangsAfter * weights.overhangs;
const overhangsCreatedPenalty = overhangsCreated * weights.overhangsCreated;
const overhangsFilledBonus = overhangsFilled * weights.overhangsFilled;
// Height added penalty - scales with current stack height
// The taller the stack, the worse it is to add more height
const heightMultiplier = 1 + (maxHeightBefore / BOARD_HEIGHT); // 1x at height 0, 2x at max height
const contactRatio = Math.min(contacts / 8, 1); // Normalize to 0-1
const heightAddedPenalty = heightAdded * weights.heightAdded * heightMultiplier * (1 - contactRatio * 0.7); // Up to 70% reduction
// Wells penalty - exponential with depth to heavily penalize deep gaps
const wellsPenalty = wells * weights.wells + (wellDepth * wellDepth * weights.wellDepthSquared);
// Bumpiness penalty - encourage smooth surface
const bumpinessPenalty = bumpiness * weights.bumpiness;
const maxHeightPenalty = maxHeightAfter * weights.maxHeight;
const avgHeightPenalty = avgHeight * weights.avgHeight;
const totalScore = lineScore + contactScore - holesPenalty - holesCreatedPenalty - overhangPenalty - overhangsCreatedPenalty + overhangsFilledBonus - heightAddedPenalty - wellsPenalty - bumpinessPenalty - maxHeightPenalty - avgHeightPenalty;
return {
score: totalScore,
breakdown: {
linesCleared,
lineScore,
contacts,
contactScore,
holes: holesAfter,
holesPenalty,
holesCreated,
holesCreatedPenalty,
overhangs: overhangsAfter,
overhangPenalty,
overhangsCreated,
overhangsCreatedPenalty,
overhangsFilled,
overhangsFilledBonus,
heightAdded,
heightAddedPenalty: Math.round(heightAddedPenalty),
wells,
wellDepth,
wellsPenalty,
bumpiness,
bumpinessPenalty,
maxHeight: maxHeightAfter,
maxHeightPenalty,
avgHeight: avgHeight.toFixed(1),
avgHeightPenalty: avgHeightPenalty.toFixed(0),
totalScore,
}
};
}
export function findBestMove(board: number[][], piece: Tetromino, weights: AIWeights = DEFAULT_WEIGHTS): Move | null {
let bestMove: Move | null = null;
let bestScore = -Infinity;
// Try all 4 rotations
let currentPiece = piece;
for (let rotation = 0; rotation < 4; rotation++) {
// Try all horizontal positions
for (let x = -3; x <= BOARD_WIDTH; x++) {
const position: Position = { x, y: 0 };
// Drop piece down until collision
let dropPosition = { ...position };
while (!checkCollision(board, currentPiece, { x: dropPosition.x, y: dropPosition.y + 1 })) {
dropPosition.y++;
}
// Check if this position is valid
if (!checkCollision(board, currentPiece, dropPosition)) {
// Evaluate this position
const evaluation = evaluatePosition(board, currentPiece, dropPosition, weights);
if (evaluation.score > bestScore) {
bestScore = evaluation.score;
bestMove = {
position: dropPosition,
rotation,
score: evaluation.score,
breakdown: evaluation.breakdown,
};
}
}
}
// Rotate for next iteration
currentPiece = rotateTetromino(currentPiece);
}
return bestMove;
}
export function getRotationCount(piece: Tetromino, targetRotation: number): number {
let currentPiece = piece;
let count = 0;
for (let i = 0; i < 4; i++) {
if (i === targetRotation) {
return count;
}
currentPiece = rotateTetromino(currentPiece);
count++;
}
return 0;
}

View File

@ -0,0 +1,189 @@
import { Tetromino, TetrominoType, Position, BOARD_WIDTH, BOARD_HEIGHT } from './types';
const TETROMINO_SHAPES: Record<TetrominoType, number[][][]> = {
I: [
[[0, 0, 0, 0], [1, 1, 1, 1], [0, 0, 0, 0], [0, 0, 0, 0]],
[[0, 0, 1, 0], [0, 0, 1, 0], [0, 0, 1, 0], [0, 0, 1, 0]],
[[0, 0, 0, 0], [0, 0, 0, 0], [1, 1, 1, 1], [0, 0, 0, 0]],
[[0, 1, 0, 0], [0, 1, 0, 0], [0, 1, 0, 0], [0, 1, 0, 0]],
],
O: [
[[1, 1], [1, 1]],
],
T: [
[[0, 1, 0], [1, 1, 1], [0, 0, 0]],
[[0, 1, 0], [0, 1, 1], [0, 1, 0]],
[[0, 0, 0], [1, 1, 1], [0, 1, 0]],
[[0, 1, 0], [1, 1, 0], [0, 1, 0]],
],
S: [
[[0, 1, 1], [1, 1, 0], [0, 0, 0]],
[[0, 1, 0], [0, 1, 1], [0, 0, 1]],
[[0, 0, 0], [0, 1, 1], [1, 1, 0]],
[[1, 0, 0], [1, 1, 0], [0, 1, 0]],
],
Z: [
[[1, 1, 0], [0, 1, 1], [0, 0, 0]],
[[0, 0, 1], [0, 1, 1], [0, 1, 0]],
[[0, 0, 0], [1, 1, 0], [0, 1, 1]],
[[0, 1, 0], [1, 1, 0], [1, 0, 0]],
],
J: [
[[1, 0, 0], [1, 1, 1], [0, 0, 0]],
[[0, 1, 1], [0, 1, 0], [0, 1, 0]],
[[0, 0, 0], [1, 1, 1], [0, 0, 1]],
[[0, 1, 0], [0, 1, 0], [1, 1, 0]],
],
L: [
[[0, 0, 1], [1, 1, 1], [0, 0, 0]],
[[0, 1, 0], [0, 1, 0], [0, 1, 1]],
[[0, 0, 0], [1, 1, 1], [1, 0, 0]],
[[1, 1, 0], [0, 1, 0], [0, 1, 0]],
],
};
const TETROMINO_COLORS: Record<TetrominoType, string> = {
I: '#00f0f0',
O: '#f0f000',
T: '#a000f0',
S: '#00f000',
Z: '#f00000',
J: '#0000f0',
L: '#f0a000',
};
export function createEmptyBoard(): number[][] {
return Array(BOARD_HEIGHT).fill(null).map(() => Array(BOARD_WIDTH).fill(0));
}
export function createTetromino(type: TetrominoType, rotation: number = 0): Tetromino {
return {
type,
shape: TETROMINO_SHAPES[type][rotation % TETROMINO_SHAPES[type].length],
color: TETROMINO_COLORS[type],
};
}
export function getRandomTetromino(): Tetromino {
const types: TetrominoType[] = ['I', 'O', 'T', 'S', 'Z', 'J', 'L'];
const randomType = types[Math.floor(Math.random() * types.length)];
return createTetromino(randomType);
}
export function rotateTetromino(piece: Tetromino): Tetromino {
const rotations = TETROMINO_SHAPES[piece.type];
const currentIndex = rotations.findIndex(
(shape) => JSON.stringify(shape) === JSON.stringify(piece.shape)
);
const nextIndex = (currentIndex + 1) % rotations.length;
return {
...piece,
shape: rotations[nextIndex],
};
}
export function checkCollision(
board: number[][],
piece: Tetromino,
position: Position
): boolean {
for (let y = 0; y < piece.shape.length; y++) {
for (let x = 0; x < piece.shape[y].length; x++) {
if (piece.shape[y][x]) {
const newX = position.x + x;
const newY = position.y + y;
if (
newX < 0 ||
newX >= BOARD_WIDTH ||
newY >= BOARD_HEIGHT ||
(newY >= 0 && board[newY][newX])
) {
return true;
}
}
}
}
return false;
}
export function mergePieceToBoard(
board: number[][],
piece: Tetromino,
position: Position
): number[][] {
const newBoard = board.map(row => [...row]);
for (let y = 0; y < piece.shape.length; y++) {
for (let x = 0; x < piece.shape[y].length; x++) {
if (piece.shape[y][x]) {
const boardY = position.y + y;
const boardX = position.x + x;
if (boardY >= 0 && boardY < BOARD_HEIGHT && boardX >= 0 && boardX < BOARD_WIDTH) {
// Store color index (1-7) based on piece type
const colorIndex = ['I', 'O', 'T', 'S', 'Z', 'J', 'L'].indexOf(piece.type) + 1;
newBoard[boardY][boardX] = colorIndex;
}
}
}
}
return newBoard;
}
export function clearLines(board: number[][]): { newBoard: number[][], linesCleared: number } {
let linesCleared = 0;
const newBoard = board.filter(row => {
if (row.every(cell => cell !== 0)) {
linesCleared++;
return false;
}
return true;
});
while (newBoard.length < BOARD_HEIGHT) {
newBoard.unshift(Array(BOARD_WIDTH).fill(0));
}
return { newBoard, linesCleared };
}
// NES Tetris speed curve (frames per drop)
const FRAMES_PER_DROP: Record<number, number> = {
0: 48, 1: 43, 2: 38, 3: 33, 4: 28, 5: 23, 6: 18, 7: 13, 8: 8, 9: 6,
10: 5, 11: 5, 12: 5, 13: 4, 14: 4, 15: 4, 16: 3, 17: 3, 18: 3,
19: 2, 20: 2, 21: 2, 22: 2, 23: 2, 24: 2, 25: 2, 26: 2, 27: 2, 28: 2,
29: 1, // Kill screen
};
export function getFramesPerDrop(level: number): number {
if (level >= 29) return 1;
return FRAMES_PER_DROP[level] || 1;
}
export function getDropSpeed(level: number): number {
// Convert frames to milliseconds (assuming 60 FPS)
const frames = getFramesPerDrop(level);
return Math.max((frames * 1000) / 60, 50); // Minimum 50ms
}
export function calculateScore(linesCleared: number, level: number): number {
// NES Tetris scoring formula
const baseScores: Record<number, number> = {
0: 0,
1: 40, // Single
2: 100, // Double
3: 300, // Triple
4: 1200, // Tetris
};
return (baseScores[linesCleared] || 0) * (level + 1);
}
export function getColorForCell(cellValue: number): string {
const colors = ['transparent', '#00f0f0', '#f0f000', '#a000f0', '#00f000', '#f00000', '#0000f0', '#f0a000'];
return colors[cellValue] || 'transparent';
}
export function getStartPosition(): Position {
return { x: Math.floor(BOARD_WIDTH / 2) - 2, y: 0 };
}

View File

@ -0,0 +1,145 @@
// Neural Network AI Player for Tetris
import { NeuralNetwork, TrainingExample } from './neuralNetwork';
import { AIWeights } from './aiPlayer';
const NETWORK_STORAGE_KEY = 'tetris-neural-network';
// Network configuration
const NETWORK_CONFIG = {
inputSize: 13, // 13 board features (same as weight count)
hiddenLayers: [20, 15], // Two hidden layers
outputSize: 13, // 13 weight outputs
learningRate: 0.01,
};
// Extract features from board state for neural network input
export const extractBoardFeatures = (debugInfo: any): number[] => {
if (!debugInfo) {
return Array(13).fill(0);
}
// Normalize features to 0-1 range for better training
return [
Math.min(debugInfo.linesCleared / 4, 1), // 0-4 lines max
Math.min(debugInfo.contacts / 10, 1), // 0-10 contacts typical
Math.min(debugInfo.holes / 20, 1), // 0-20 holes
Math.min(debugInfo.holesCreated / 5, 1), // 0-5 new holes
Math.min(debugInfo.overhangs / 20, 1), // 0-20 overhangs
Math.min(debugInfo.overhangsCreated / 5, 1), // 0-5 new overhangs
Math.min(debugInfo.overhangsFilled / 5, 1), // 0-5 filled
Math.min(debugInfo.heightAdded / 10, 1), // 0-10 height added
Math.min(debugInfo.wells / 10, 1), // 0-10 wells
Math.min(debugInfo.wellDepth / 10, 1), // 0-10 depth
Math.min(debugInfo.bumpiness / 20, 1), // 0-20 bumpiness
Math.min(debugInfo.maxHeight / 20, 1), // 0-20 max height
Math.min(parseFloat(debugInfo.avgHeight) / 10, 1), // 0-10 avg height
];
};
// Convert neural network output to weights
export const outputToWeights = (output: number[]): AIWeights => {
// Scale outputs to reasonable weight ranges
return {
lineCleared: output[0] * 20000, // 0-20000
contact: output[1] * 500, // 0-500
holes: output[2] * 2000, // 0-2000
holesCreated: output[3] * 2000, // 0-2000
overhangs: output[4] * 2000, // 0-2000
overhangsCreated: output[5] * 3000, // 0-3000
overhangsFilled: output[6] * 1000, // 0-1000
heightAdded: output[7] * 2000, // 0-2000
wells: output[8] * 500, // 0-500
wellDepthSquared: output[9] * 500, // 0-500
bumpiness: output[10] * 200, // 0-200
maxHeight: output[11] * 200, // 0-200
avgHeight: output[12] * 100, // 0-100
};
};
// Convert weights to neural network target output
export const weightsToOutput = (weights: AIWeights): number[] => {
return [
Math.min(weights.lineCleared / 20000, 1),
Math.min(weights.contact / 500, 1),
Math.min(weights.holes / 2000, 1),
Math.min(weights.holesCreated / 2000, 1),
Math.min(weights.overhangs / 2000, 1),
Math.min(weights.overhangsCreated / 3000, 1),
Math.min(weights.overhangsFilled / 1000, 1),
Math.min(weights.heightAdded / 2000, 1),
Math.min(weights.wells / 500, 1),
Math.min(weights.wellDepthSquared / 500, 1),
Math.min(weights.bumpiness / 200, 1),
Math.min(weights.maxHeight / 200, 1),
Math.min(weights.avgHeight / 100, 1),
];
};
// Calculate reward based on game performance
export const calculateReward = (score: number, lines: number, level: number): number => {
// Reward function: prioritize score and lines cleared
// Normalize to 0-1 range, with exponential scaling for high scores
const scoreReward = Math.min(score / 100000, 1) ** 0.5; // Square root for smoother scaling
const linesReward = Math.min(lines / 100, 1);
const levelReward = Math.min(level / 10, 1);
// Weighted combination
return scoreReward * 0.6 + linesReward * 0.3 + levelReward * 0.1;
};
// Get or create neural network
export const getNeuralNetwork = (): NeuralNetwork => {
const stored = localStorage.getItem(NETWORK_STORAGE_KEY);
if (stored) {
try {
return NeuralNetwork.fromJSON(stored);
} catch (e) {
console.warn('Failed to load neural network, creating new one');
}
}
return new NeuralNetwork(NETWORK_CONFIG);
};
// Save neural network
export const saveNeuralNetwork = (network: NeuralNetwork): void => {
localStorage.setItem(NETWORK_STORAGE_KEY, network.toJSON());
};
// Train network on game history
export const trainOnHistory = (network: NeuralNetwork): void => {
const history = JSON.parse(localStorage.getItem('tetris-game-history') || '[]');
if (history.length === 0) return;
const examples: TrainingExample[] = history.map((game: any) => {
// We don't have board features from history, so we'll use a simplified approach
// In a real implementation, you'd store board states during gameplay
const reward = calculateReward(game.score, game.lines, game.level);
// Use dummy input (in real version, store actual board states)
const input = Array(13).fill(0.5);
const expectedOutput = weightsToOutput(game.weights);
return { input, expectedOutput, reward };
});
// Train in batches
const batchSize = 10;
for (let i = 0; i < examples.length; i += batchSize) {
const batch = examples.slice(i, i + batchSize);
network.train(batch);
}
saveNeuralNetwork(network);
};
// Get weights from neural network prediction
export const getNeuralWeights = (boardFeatures?: number[]): AIWeights => {
const network = getNeuralNetwork();
// If no features provided, use neutral input
const input = boardFeatures || Array(13).fill(0.5);
const output = network.predict(input);
return outputToWeights(output);
};

View File

@ -0,0 +1,281 @@
// Neural Network for Tetris AI
// Uses a simple feedforward network with backpropagation
export interface NeuralNetworkConfig {
inputSize: number;
hiddenLayers: number[];
outputSize: number;
learningRate: number;
}
export interface TrainingExample {
input: number[];
expectedOutput: number[];
reward: number;
}
class Matrix {
rows: number;
cols: number;
data: number[][];
constructor(rows: number, cols: number) {
this.rows = rows;
this.cols = cols;
this.data = Array(rows).fill(0).map(() => Array(cols).fill(0));
}
static fromArray(arr: number[]): Matrix {
const m = new Matrix(arr.length, 1);
for (let i = 0; i < arr.length; i++) {
m.data[i][0] = arr[i];
}
return m;
}
toArray(): number[] {
const arr: number[] = [];
for (let i = 0; i < this.rows; i++) {
for (let j = 0; j < this.cols; j++) {
arr.push(this.data[i][j]);
}
}
return arr;
}
randomize(): void {
for (let i = 0; i < this.rows; i++) {
for (let j = 0; j < this.cols; j++) {
this.data[i][j] = Math.random() * 2 - 1; // -1 to 1
}
}
}
static multiply(a: Matrix, b: Matrix): Matrix {
if (a.cols !== b.rows) {
throw new Error('Matrix dimensions do not match for multiplication');
}
const result = new Matrix(a.rows, b.cols);
for (let i = 0; i < result.rows; i++) {
for (let j = 0; j < result.cols; j++) {
let sum = 0;
for (let k = 0; k < a.cols; k++) {
sum += a.data[i][k] * b.data[k][j];
}
result.data[i][j] = sum;
}
}
return result;
}
add(n: Matrix | number): void {
if (n instanceof Matrix) {
for (let i = 0; i < this.rows; i++) {
for (let j = 0; j < this.cols; j++) {
this.data[i][j] += n.data[i][j];
}
}
} else {
for (let i = 0; i < this.rows; i++) {
for (let j = 0; j < this.cols; j++) {
this.data[i][j] += n;
}
}
}
}
static subtract(a: Matrix, b: Matrix): Matrix {
const result = new Matrix(a.rows, a.cols);
for (let i = 0; i < a.rows; i++) {
for (let j = 0; j < a.cols; j++) {
result.data[i][j] = a.data[i][j] - b.data[i][j];
}
}
return result;
}
static transpose(m: Matrix): Matrix {
const result = new Matrix(m.cols, m.rows);
for (let i = 0; i < m.rows; i++) {
for (let j = 0; j < m.cols; j++) {
result.data[j][i] = m.data[i][j];
}
}
return result;
}
map(func: (val: number) => number): Matrix {
const result = new Matrix(this.rows, this.cols);
for (let i = 0; i < this.rows; i++) {
for (let j = 0; j < this.cols; j++) {
result.data[i][j] = func(this.data[i][j]);
}
}
return result;
}
static hadamard(a: Matrix, b: Matrix): Matrix {
const result = new Matrix(a.rows, a.cols);
for (let i = 0; i < a.rows; i++) {
for (let j = 0; j < a.cols; j++) {
result.data[i][j] = a.data[i][j] * b.data[i][j];
}
}
return result;
}
scale(n: number): void {
for (let i = 0; i < this.rows; i++) {
for (let j = 0; j < this.cols; j++) {
this.data[i][j] *= n;
}
}
}
copy(): Matrix {
const result = new Matrix(this.rows, this.cols);
for (let i = 0; i < this.rows; i++) {
for (let j = 0; j < this.cols; j++) {
result.data[i][j] = this.data[i][j];
}
}
return result;
}
}
// Activation functions
const sigmoid = (x: number): number => 1 / (1 + Math.exp(-x));
const sigmoidDerivative = (y: number): number => y * (1 - y);
const relu = (x: number): number => Math.max(0, x);
const reluDerivative = (y: number): number => y > 0 ? 1 : 0;
export class NeuralNetwork {
config: NeuralNetworkConfig;
weights: Matrix[];
biases: Matrix[];
constructor(config: NeuralNetworkConfig) {
this.config = config;
this.weights = [];
this.biases = [];
// Initialize weights and biases for each layer
const layerSizes = [config.inputSize, ...config.hiddenLayers, config.outputSize];
for (let i = 0; i < layerSizes.length - 1; i++) {
const w = new Matrix(layerSizes[i + 1], layerSizes[i]);
w.randomize();
this.weights.push(w);
const b = new Matrix(layerSizes[i + 1], 1);
b.randomize();
this.biases.push(b);
}
}
// Forward propagation
predict(inputArray: number[]): number[] {
let current = Matrix.fromArray(inputArray);
for (let i = 0; i < this.weights.length; i++) {
current = Matrix.multiply(this.weights[i], current);
current.add(this.biases[i]);
// Use ReLU for hidden layers, sigmoid for output
if (i < this.weights.length - 1) {
current = current.map(relu);
} else {
current = current.map(sigmoid);
}
}
return current.toArray();
}
// Backpropagation training
train(examples: TrainingExample[]): void {
for (const example of examples) {
// Forward pass - store all activations
const activations: Matrix[] = [Matrix.fromArray(example.input)];
const zValues: Matrix[] = [];
for (let i = 0; i < this.weights.length; i++) {
const z = Matrix.multiply(this.weights[i], activations[activations.length - 1]);
z.add(this.biases[i]);
zValues.push(z);
let a: Matrix;
if (i < this.weights.length - 1) {
a = z.map(relu);
} else {
a = z.map(sigmoid);
}
activations.push(a);
}
// Backward pass
const target = Matrix.fromArray(example.expectedOutput);
const weightGradients: Matrix[] = [];
const biasGradients: Matrix[] = [];
// Output layer error (scaled by reward)
let delta = Matrix.subtract(activations[activations.length - 1], target);
delta.scale(example.reward); // Reward modulation
const outputDerivative = activations[activations.length - 1].map(sigmoidDerivative);
delta = Matrix.hadamard(delta, outputDerivative);
// Calculate gradients for each layer (backwards)
for (let i = this.weights.length - 1; i >= 0; i--) {
const weightGrad = Matrix.multiply(delta, Matrix.transpose(activations[i]));
weightGradients.unshift(weightGrad);
biasGradients.unshift(delta.copy());
if (i > 0) {
// Propagate error to previous layer
delta = Matrix.multiply(Matrix.transpose(this.weights[i]), delta);
const derivative = zValues[i - 1].map(reluDerivative);
delta = Matrix.hadamard(delta, derivative);
}
}
// Update weights and biases
for (let i = 0; i < this.weights.length; i++) {
weightGradients[i].scale(this.config.learningRate);
this.weights[i].add(weightGradients[i].map(x => -x));
biasGradients[i].scale(this.config.learningRate);
this.biases[i].add(biasGradients[i].map(x => -x));
}
}
}
// Serialize to JSON for storage
toJSON(): string {
return JSON.stringify({
config: this.config,
weights: this.weights.map(w => w.data),
biases: this.biases.map(b => b.data),
});
}
// Deserialize from JSON
static fromJSON(json: string): NeuralNetwork {
const data = JSON.parse(json);
const nn = new NeuralNetwork(data.config);
nn.weights = data.weights.map((w: number[][]) => {
const matrix = new Matrix(w.length, w[0].length);
matrix.data = w;
return matrix;
});
nn.biases = data.biases.map((b: number[][]) => {
const matrix = new Matrix(b.length, b[0].length);
matrix.data = b;
return matrix;
});
return nn;
}
}

View File

@ -0,0 +1,36 @@
export type TetrominoType = 'I' | 'O' | 'T' | 'S' | 'Z' | 'J' | 'L';
export interface Position {
x: number;
y: number;
}
export interface Tetromino {
type: TetrominoType;
shape: number[][];
color: string;
}
export interface GameState {
board: number[][];
currentPiece: Tetromino | null;
currentPosition: Position;
nextPiece: Tetromino | null;
score: number;
level: number;
lines: number;
gameOver: boolean;
isPaused: boolean;
isAutoPlay: boolean;
}
export interface Move {
position: Position;
rotation: number;
score: number;
breakdown?: any;
}
export const BOARD_WIDTH = 10;
export const BOARD_HEIGHT = 20;
export const CELL_SIZE = 30;

View File

@ -2,7 +2,7 @@ import { useState } from "react";
import { supabase } from "@/integrations/supabase/client";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Eye, EyeOff, Edit3, Trash2, GitMerge, Share2, Link as LinkIcon, FileText, Download, FilePlus, FolderTree } from "lucide-react";
import { Eye, EyeOff, Edit3, Trash2, GitMerge, Share2, Link as LinkIcon, FileText, Download, FilePlus, FolderTree, FileJson } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
@ -409,6 +409,18 @@ draft: ${!page.visible}
}
};
const handleDumpJson = async () => {
try {
const pageJson = JSON.stringify(page, null, 2);
console.log('Page JSON:', pageJson);
await navigator.clipboard.writeText(pageJson);
toast.success("Page JSON dumped to console and clipboard");
} catch (e) {
console.error("Failed to dump JSON", e);
toast.error("Failed to dump JSON");
}
};
return (
<div className={cn("flex items-center gap-2", className)}>
{/* Share Menu */}
@ -565,6 +577,20 @@ draft: ${!page.visible}
parentId={page.id}
/>
{/* Dev Mode: Dump JSON */}
{import.meta.env.DEV && (
<Button
variant="outline"
size="sm"
onClick={(e) => { e.stopPropagation(); handleDumpJson(); }}
className="text-muted-foreground hover:text-foreground"
title="Dump Page JSON (Dev Mode)"
>
<FileJson className="h-4 w-4" />
{showLabels && <span className="ml-2 hidden md:inline"><T>Dump JSON</T></span>}
</Button>
)}
{onDelete && (
<Button
size="sm"

View File

@ -28,7 +28,16 @@ const GalleryWidget: React.FC<GalleryWidgetProps> = ({
id
}) => {
const { user } = useAuth();
const [pictureIds, setPictureIds] = useState<string[]>(propPictureIds || []);
// Normalize pictureIds to always be an array
const normalizePictureIds = (ids: any): string[] => {
if (!ids) return [];
if (Array.isArray(ids)) return ids;
if (typeof ids === 'string') return [ids];
return [];
};
const [pictureIds, setPictureIds] = useState<string[]>(normalizePictureIds(propPictureIds));
const [mediaItems, setMediaItems] = useState<PostMediaItem[]>([]);
const [selectedItem, setSelectedItem] = useState<PostMediaItem | null>(null);
const [loading, setLoading] = useState(false);
@ -37,8 +46,9 @@ const GalleryWidget: React.FC<GalleryWidgetProps> = ({
// Sync local state with props
useEffect(() => {
if (JSON.stringify(propPictureIds) !== JSON.stringify(pictureIds)) {
setPictureIds(propPictureIds || []);
const normalizedProps = normalizePictureIds(propPictureIds);
if (JSON.stringify(normalizedProps) !== JSON.stringify(pictureIds)) {
setPictureIds(normalizedProps);
}
}, [propPictureIds]);

View File

@ -153,7 +153,7 @@ export const fetchMediaItemsByIds = async (
}
// Call server API endpoint
const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3333';
const serverUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || 'http://localhost:3333';
const response = await fetch(`${serverUrl}/api/media-items?${params.toString()}`);
if (!response.ok) {

View File

@ -1,21 +1,76 @@
import { useLocation } from "react-router-dom";
import { useEffect } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useEffect, useState } from "react";
const NotFound = () => {
const location = useLocation();
const navigate = useNavigate();
const [countdown, setCountdown] = useState(5);
useEffect(() => {
console.error("404 Error: User attempted to access non-existent route:", location.pathname);
}, [location.pathname]);
useEffect(() => {
const timer = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
clearInterval(timer);
// Defer navigation to next tick to avoid setState during render
setTimeout(() => navigate("/app/tetris"), 0);
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(timer);
}, [navigate]);
return (
<div className="flex min-h-screen items-center justify-center bg-gray-100">
<div className="text-center">
<h1 className="mb-4 text-4xl font-bold">404</h1>
<p className="mb-4 text-xl text-gray-600">Oops! Page not found</p>
<a href="/" className="text-blue-500 underline hover:text-blue-700">
Return to Home
</a>
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-gray-900 via-purple-900 to-violet-900">
<div className="text-center space-y-8 p-8">
<div className="space-y-4">
<h1 className="text-9xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 to-purple-400 animate-pulse">
404
</h1>
<p className="text-2xl text-gray-300">Oops! Page not found</p>
</div>
<div className="space-y-4">
<p className="text-lg text-gray-400">
Don't worry, we'll redirect you to something fun...
</p>
<div className="flex items-center justify-center gap-4">
<div className="relative">
<div className="text-6xl font-bold text-cyan-400 animate-bounce">
{countdown}
</div>
<div className="absolute inset-0 text-6xl font-bold text-cyan-400 blur-xl opacity-50 animate-pulse">
{countdown}
</div>
</div>
</div>
<p className="text-sm text-gray-500">
Redirecting to Tetris in {countdown} second{countdown !== 1 ? 's' : ''}...
</p>
</div>
<div className="flex gap-4 justify-center">
<a
href="/"
className="px-6 py-3 bg-gradient-to-r from-blue-500 to-blue-600 text-white rounded-lg hover:from-blue-600 hover:to-blue-700 transition-all shadow-lg hover:shadow-xl"
>
Return to Home
</a>
<a
href="/app/tetris"
className="px-6 py-3 bg-gradient-to-r from-purple-500 to-purple-600 text-white rounded-lg hover:from-purple-600 hover:to-purple-700 transition-all shadow-lg hover:shadow-xl"
>
Play Tetris Now
</a>
</div>
</div>
</div>
);

View File

@ -83,6 +83,7 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia
const [isEditMode, setIsEditMode] = useState(false);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
// Inline editing states
const [editingTitle, setEditingTitle] = useState(false);
const [editingSlug, setEditingSlug] = useState(false);
@ -97,6 +98,13 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia
// TOC State
const [headings, setHeadings] = useState<MarkdownHeading[]>([]);
// Auto-collapse sidebar if no TOC headings
useEffect(() => {
if (headings.length === 0) {
setIsSidebarCollapsed(true);
}
}, [headings.length]);
const isOwner = currentUser?.id === userId;
useEffect(() => {
@ -460,9 +468,6 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia
{/* Page Header */}
<div className="mb-8">
<div className="flex items-start gap-4 mb-4">
<div className="w-16 h-16 bg-gradient-primary rounded-lg flex items-center justify-center flex-shrink-0">
<FileText className="h-8 w-8 text-white" />
</div>
<div className="flex-1">
{/* Parent Page Eyebrow */}
{page.parent_page && (
@ -576,9 +581,6 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia
{/* Tags and Type */}
<div className="space-y-3 mb-8">
<div className="flex items-center gap-2 flex-wrap w-full">
{page.type && (
<Badge variant="outline">{page.type}</Badge>
)}
{!page.visible && isOwner && (
<Badge variant="destructive" className="flex items-center gap-1">
<EyeOff className="h-3 w-3" />
@ -591,17 +593,14 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia
</Badge>
)}
<div className="ml-auto">
<PageActions
page={page}
isOwner={isOwner}
isEditMode={isEditMode}
onToggleEditMode={() => setIsEditMode(!isEditMode)}
onPageUpdate={handlePageUpdate}
onMetaUpdated={() => userId && page.slug && fetchUserPageData(userId, page.slug)}
className="ml-auto"
/>
</div>
<PageActions
page={page}
isOwner={isOwner}
isEditMode={isEditMode}
onToggleEditMode={() => setIsEditMode(!isEditMode)}
onPageUpdate={handlePageUpdate}
onMetaUpdated={() => userId && page.slug && fetchUserPageData(userId, page.slug)}
/>
</div>
<Separator className="my-6" />