play time | basic shit
This commit is contained in:
parent
34fe0f1690
commit
fff4359515
@ -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 >
|
||||
|
||||
95
packages/ui/src/apps/tetris/LearningLog.tsx
Normal file
95
packages/ui/src/apps/tetris/LearningLog.tsx
Normal 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 (<5% change)</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
289
packages/ui/src/apps/tetris/NeuralNetworkVisualizer.tsx
Normal file
289
packages/ui/src/apps/tetris/NeuralNetworkVisualizer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
153
packages/ui/src/apps/tetris/PerformanceChart.tsx
Normal file
153
packages/ui/src/apps/tetris/PerformanceChart.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1079
packages/ui/src/apps/tetris/Tetris.tsx
Normal file
1079
packages/ui/src/apps/tetris/Tetris.tsx
Normal file
File diff suppressed because it is too large
Load Diff
177
packages/ui/src/apps/tetris/WeightsTuner.tsx
Normal file
177
packages/ui/src/apps/tetris/WeightsTuner.tsx
Normal 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;
|
||||
};
|
||||
328
packages/ui/src/apps/tetris/aiPlayer.ts
Normal file
328
packages/ui/src/apps/tetris/aiPlayer.ts
Normal 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;
|
||||
}
|
||||
189
packages/ui/src/apps/tetris/gameLogic.ts
Normal file
189
packages/ui/src/apps/tetris/gameLogic.ts
Normal 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 };
|
||||
}
|
||||
145
packages/ui/src/apps/tetris/neuralAI.ts
Normal file
145
packages/ui/src/apps/tetris/neuralAI.ts
Normal 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);
|
||||
};
|
||||
281
packages/ui/src/apps/tetris/neuralNetwork.ts
Normal file
281
packages/ui/src/apps/tetris/neuralNetwork.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
36
packages/ui/src/apps/tetris/types.ts
Normal file
36
packages/ui/src/apps/tetris/types.ts
Normal 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;
|
||||
@ -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"
|
||||
|
||||
@ -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]);
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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" />
|
||||
|
||||
Loading…
Reference in New Issue
Block a user