Add bridge builder (not working) and asteroids
This commit is contained in:
@@ -11,6 +11,7 @@ This project features several mini-apps that showcase evolutionary algorithms an
|
|||||||
- **Snake AI**: Watch neural networks learn to play Snake through evolution
|
- **Snake AI**: Watch neural networks learn to play Snake through evolution
|
||||||
- **Self-Driving Car**: AI learns to navigate a race track using genetic algorithms
|
- **Self-Driving Car**: AI learns to navigate a race track using genetic algorithms
|
||||||
- **Lunar Lander**: Evolutionary training for optimal lunar landing with gimballed thrust control
|
- **Lunar Lander**: Evolutionary training for optimal lunar landing with gimballed thrust control
|
||||||
|
- **Bridge Builder**: Evolve bridge structures with stress visualization and physics
|
||||||
- **Image Approximation**: Genetic algorithm that evolves shapes to approximate a target image
|
- **Image Approximation**: Genetic algorithm that evolves shapes to approximate a target image
|
||||||
- **NEAT Arena**: Neural evolution of augmenting topologies in a competitive environment
|
- **NEAT Arena**: Neural evolution of augmenting topologies in a competitive environment
|
||||||
- **Rogue Gen**: Procedural dungeon generation using evolutionary techniques
|
- **Rogue Gen**: Procedural dungeon generation using evolutionary techniques
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import RogueGenApp from './apps/RogueGen/RogueGenApp';
|
|||||||
import NeatArena from './apps/NeatArena/NeatArena';
|
import NeatArena from './apps/NeatArena/NeatArena';
|
||||||
import LunarLanderApp from './apps/LunarLander/LunarLanderApp';
|
import LunarLanderApp from './apps/LunarLander/LunarLanderApp';
|
||||||
import { SelfDrivingCarApp } from './apps/SelfDrivingCar/SelfDrivingCarApp';
|
import { SelfDrivingCarApp } from './apps/SelfDrivingCar/SelfDrivingCarApp';
|
||||||
|
import BridgeBuilderApp from './apps/BridgeBuilder/BridgeBuilderApp';
|
||||||
|
import AsteroidsAI from './apps/AsteroidsAI/AsteroidsApp';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -21,6 +23,8 @@ function App() {
|
|||||||
<Route path="/neat-arena" element={<NeatArena />} />
|
<Route path="/neat-arena" element={<NeatArena />} />
|
||||||
<Route path="/lunar-lander" element={<LunarLanderApp />} />
|
<Route path="/lunar-lander" element={<LunarLanderApp />} />
|
||||||
<Route path="/self-driving-car" element={<SelfDrivingCarApp />} />
|
<Route path="/self-driving-car" element={<SelfDrivingCarApp />} />
|
||||||
|
<Route path="/bridge-builder" element={<BridgeBuilderApp />} />
|
||||||
|
<Route path="/asteroids-ai" element={<AsteroidsAI />} />
|
||||||
<Route path="*" element={<div>App not found</div>} />
|
<Route path="*" element={<div>App not found</div>} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
125
src/apps/AsteroidsAI/Asteroids.css
Normal file
125
src/apps/AsteroidsAI/Asteroids.css
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
.asteroids-app-layout {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: linear-gradient(135deg, #0a0a15 0%, #1a1a2e 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 12px;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-section {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-toggle {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: linear-gradient(135deg, #4488ff 0%, #6666ff 100%);
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 4px 15px rgba(68, 136, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-toggle:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 20px rgba(68, 136, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-toggle.active {
|
||||||
|
background: linear-gradient(135deg, #ff6b6b 0%, #ff8888 100%);
|
||||||
|
box-shadow: 0 4px 15px rgba(255, 107, 107, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-reset {
|
||||||
|
background: linear-gradient(135deg, #888888 0%, #aaaaaa 100%);
|
||||||
|
box-shadow: 0 4px 15px rgba(136, 136, 136, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-reset:hover {
|
||||||
|
box-shadow: 0 6px 20px rgba(136, 136, 136, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-section {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #aaaaaa;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value.highlight {
|
||||||
|
color: #ffaa00;
|
||||||
|
text-shadow: 0 0 10px rgba(255, 170, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-panel {
|
||||||
|
flex: 0 0 200px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vis-column {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-view {
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-view canvas {
|
||||||
|
display: block;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
82
src/apps/AsteroidsAI/AsteroidsApp.tsx
Normal file
82
src/apps/AsteroidsAI/AsteroidsApp.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { useRef, useEffect } from 'react';
|
||||||
|
import AppContainer from '../../components/AppContainer';
|
||||||
|
import { createAsteroidsViewer, getAsteroidsScene } from './AsteroidsScene';
|
||||||
|
import FitnessGraph from '../NeatArena/FitnessGraph';
|
||||||
|
import { useEvolutionWorker } from './useEvolutionWorker';
|
||||||
|
import ConfigPanel from './ConfigPanel';
|
||||||
|
import './Asteroids.css';
|
||||||
|
|
||||||
|
export default function AsteroidsApp() {
|
||||||
|
const { isTraining, stats, fitnessHistory, bestGenome, toggleTraining, handleReset } = useEvolutionWorker();
|
||||||
|
const phaserContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const phaserGameRef = useRef<Phaser.Game | null>(null);
|
||||||
|
|
||||||
|
// Phaser Initialization
|
||||||
|
useEffect(() => {
|
||||||
|
if (!phaserContainerRef.current) return;
|
||||||
|
const game = createAsteroidsViewer(phaserContainerRef.current);
|
||||||
|
phaserGameRef.current = game;
|
||||||
|
return () => {
|
||||||
|
game.destroy(true);
|
||||||
|
phaserGameRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Exhibition Loop
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (!phaserGameRef.current) return;
|
||||||
|
const scene = getAsteroidsScene(phaserGameRef.current);
|
||||||
|
if (!scene) return;
|
||||||
|
|
||||||
|
// Start new match if game over and we have a genome
|
||||||
|
const sceneAny = scene as any;
|
||||||
|
if (bestGenome && (!sceneAny.sim || sceneAny.sim.isGameOver)) {
|
||||||
|
scene.startMatch(bestGenome, stats.generation);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [bestGenome, stats.generation]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppContainer title="Asteroids AI (Dense NN)">
|
||||||
|
<div className="asteroids-app-layout">
|
||||||
|
<div className="top-bar">
|
||||||
|
<div className="controls-section">
|
||||||
|
<button className={`btn-toggle ${isTraining ? 'active' : ''}`} onClick={toggleTraining}>
|
||||||
|
{isTraining ? '⏸ Pause' : '▶ Start Evolution'}
|
||||||
|
</button>
|
||||||
|
<button className="btn-toggle btn-reset" onClick={handleReset}>
|
||||||
|
🔄 Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stats-section">
|
||||||
|
<StatCard label="Generation" value={stats.generation} />
|
||||||
|
<StatCard label="Best Fit" value={stats.maxFitness.toFixed(2)} highlight />
|
||||||
|
<StatCard label="Avg Fit" value={stats.avgFitness.toFixed(2)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfigPanel />
|
||||||
|
|
||||||
|
<div className="graph-panel">
|
||||||
|
<FitnessGraph history={fitnessHistory} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="vis-column">
|
||||||
|
<div className="main-view" ref={phaserContainerRef} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ label, value, highlight = false }: { label: string, value: string | number, highlight?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-label">{label}</div>
|
||||||
|
<div className={`stat-value ${highlight ? 'highlight' : ''}`}>{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
288
src/apps/AsteroidsAI/AsteroidsScene.ts
Normal file
288
src/apps/AsteroidsAI/AsteroidsScene.ts
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
import Phaser from 'phaser';
|
||||||
|
import { AsteroidsSimulation, WORLD_WIDTH, WORLD_HEIGHT } from './AsteroidsSimulation';
|
||||||
|
import { DenseNetwork } from './DenseNetwork';
|
||||||
|
import { CONFIG, getLayerSizes } from './config';
|
||||||
|
|
||||||
|
export class AsteroidsScene extends Phaser.Scene {
|
||||||
|
private sim: AsteroidsSimulation | null = null;
|
||||||
|
private network: DenseNetwork | null = null;
|
||||||
|
private generation = 0;
|
||||||
|
|
||||||
|
// Graphics
|
||||||
|
private shipGraphics!: Phaser.GameObjects.Graphics;
|
||||||
|
private asteroidGraphics!: Phaser.GameObjects.Graphics;
|
||||||
|
private bulletGraphics!: Phaser.GameObjects.Graphics;
|
||||||
|
private debugGraphics!: Phaser.GameObjects.Graphics;
|
||||||
|
|
||||||
|
// Particle emitters
|
||||||
|
private thrusterEmitter!: Phaser.GameObjects.Particles.ParticleEmitter;
|
||||||
|
|
||||||
|
// HUD
|
||||||
|
private scoreText!: Phaser.GameObjects.Text;
|
||||||
|
private generationText!: Phaser.GameObjects.Text;
|
||||||
|
|
||||||
|
private showDebug = CONFIG.SHOW_RAYCASTS;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super({ key: 'AsteroidsScene' });
|
||||||
|
}
|
||||||
|
|
||||||
|
preload() {
|
||||||
|
// Create particle texture using graphics
|
||||||
|
const graphics = this.make.graphics({});
|
||||||
|
graphics.fillStyle(0xffffff, 1);
|
||||||
|
graphics.fillCircle(8, 8, 8);
|
||||||
|
graphics.generateTexture('particle', 16, 16);
|
||||||
|
graphics.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
create() {
|
||||||
|
// Background
|
||||||
|
this.cameras.main.setBackgroundColor('#0a0a15');
|
||||||
|
|
||||||
|
// Graphics layers
|
||||||
|
this.debugGraphics = this.add.graphics();
|
||||||
|
this.asteroidGraphics = this.add.graphics();
|
||||||
|
this.bulletGraphics = this.add.graphics();
|
||||||
|
this.shipGraphics = this.add.graphics();
|
||||||
|
|
||||||
|
// Particle system for thrusters
|
||||||
|
this.thrusterEmitter = this.add.particles(0, 0, 'particle', {
|
||||||
|
speed: { min: 20, max: 50 },
|
||||||
|
scale: { start: 0.6, end: 0 },
|
||||||
|
alpha: { start: 0.8, end: 0 },
|
||||||
|
lifespan: 300,
|
||||||
|
blendMode: 'ADD',
|
||||||
|
tint: [0x4488ff, 0x88ccff, 0xffffff],
|
||||||
|
frequency: 30,
|
||||||
|
emitting: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// HUD
|
||||||
|
this.scoreText = this.add.text(10, 10, 'Score: 0', {
|
||||||
|
fontSize: '20px',
|
||||||
|
color: '#ffffff',
|
||||||
|
fontFamily: 'monospace'
|
||||||
|
});
|
||||||
|
|
||||||
|
this.generationText = this.add.text(10, 40, 'Gen: 0', {
|
||||||
|
fontSize: '16px',
|
||||||
|
color: '#aaaaaa',
|
||||||
|
fontFamily: 'monospace'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
update() {
|
||||||
|
if (!this.sim || !this.network) return;
|
||||||
|
|
||||||
|
// Get AI decision
|
||||||
|
const inputs = this.sim.getObservation();
|
||||||
|
const outputs = this.network.predict(inputs);
|
||||||
|
|
||||||
|
// Update simulation
|
||||||
|
const isRunning = this.sim.update(outputs);
|
||||||
|
|
||||||
|
// Render
|
||||||
|
this.render();
|
||||||
|
|
||||||
|
// Update HUD
|
||||||
|
this.scoreText.setText(`Score: ${this.sim.score}`);
|
||||||
|
this.generationText.setText(`Gen: ${this.generation} | Time: ${this.sim.timeSteps}`);
|
||||||
|
|
||||||
|
// Thruster particles
|
||||||
|
if (outputs[1] > 0.1) { // Thrust active
|
||||||
|
const angle = this.sim.ship.angle;
|
||||||
|
const offset = 15;
|
||||||
|
const pos = {
|
||||||
|
x: this.sim.ship.position.x - Math.cos(angle) * offset,
|
||||||
|
y: this.sim.ship.position.y - Math.sin(angle) * offset
|
||||||
|
};
|
||||||
|
|
||||||
|
this.thrusterEmitter.setPosition(pos.x, pos.y);
|
||||||
|
this.thrusterEmitter.setAngle(Phaser.Math.RadToDeg(angle + Math.PI));
|
||||||
|
this.thrusterEmitter.emitting = true;
|
||||||
|
} else {
|
||||||
|
this.thrusterEmitter.emitting = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Game over - create explosion
|
||||||
|
if (!isRunning && this.sim.isGameOver) {
|
||||||
|
this.createExplosion(this.sim.ship.position.x, this.sim.ship.position.y, 0xff4444, 30);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private render() {
|
||||||
|
if (!this.sim) return;
|
||||||
|
|
||||||
|
// Clear graphics
|
||||||
|
this.shipGraphics.clear();
|
||||||
|
this.asteroidGraphics.clear();
|
||||||
|
this.bulletGraphics.clear();
|
||||||
|
this.debugGraphics.clear();
|
||||||
|
|
||||||
|
// Draw ship
|
||||||
|
this.drawShip();
|
||||||
|
|
||||||
|
// Draw asteroids
|
||||||
|
this.drawAsteroids();
|
||||||
|
|
||||||
|
// Draw bullets
|
||||||
|
this.drawBullets();
|
||||||
|
|
||||||
|
// Draw debug info
|
||||||
|
if (this.showDebug) {
|
||||||
|
this.drawDebug();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawShip() {
|
||||||
|
if (!this.sim) return;
|
||||||
|
|
||||||
|
const { position, angle } = this.sim.ship;
|
||||||
|
|
||||||
|
this.shipGraphics.lineStyle(2, 0xffffff, 1);
|
||||||
|
this.shipGraphics.fillStyle(0x4488ff, 0.3);
|
||||||
|
|
||||||
|
// Triangle pointing in direction of angle
|
||||||
|
const size = 15;
|
||||||
|
const points = [
|
||||||
|
{ x: Math.cos(angle) * size, y: Math.sin(angle) * size },
|
||||||
|
{ x: Math.cos(angle + 2.5) * size * 0.6, y: Math.sin(angle + 2.5) * size * 0.6 },
|
||||||
|
{ x: Math.cos(angle - 2.5) * size * 0.6, y: Math.sin(angle - 2.5) * size * 0.6 }
|
||||||
|
];
|
||||||
|
|
||||||
|
this.shipGraphics.beginPath();
|
||||||
|
this.shipGraphics.moveTo(position.x + points[0].x, position.y + points[0].y);
|
||||||
|
this.shipGraphics.lineTo(position.x + points[1].x, position.y + points[1].y);
|
||||||
|
this.shipGraphics.lineTo(position.x + points[2].x, position.y + points[2].y);
|
||||||
|
this.shipGraphics.closePath();
|
||||||
|
this.shipGraphics.fillPath();
|
||||||
|
this.shipGraphics.strokePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawAsteroids() {
|
||||||
|
if (!this.sim) return;
|
||||||
|
|
||||||
|
for (const asteroid of this.sim.asteroids) {
|
||||||
|
const vertices = asteroid.body.vertices;
|
||||||
|
|
||||||
|
// Check if this asteroid is detected by raycasts
|
||||||
|
const isDetected = this.sim.detectedAsteroids.has(asteroid.body);
|
||||||
|
|
||||||
|
// Color based on detection status and size
|
||||||
|
let color: number;
|
||||||
|
let fillAlpha: number;
|
||||||
|
|
||||||
|
if (isDetected) {
|
||||||
|
// Detected asteroids are highlighted in orange
|
||||||
|
color = 0xff8800;
|
||||||
|
fillAlpha = 0.4;
|
||||||
|
} else {
|
||||||
|
// Undetected asteroids are gray
|
||||||
|
color = asteroid.size === 'large' ? 0x888888 :
|
||||||
|
asteroid.size === 'medium' ? 0x999999 : 0xaaaaaa;
|
||||||
|
fillAlpha = 0.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.asteroidGraphics.lineStyle(2, color, 1);
|
||||||
|
this.asteroidGraphics.fillStyle(color, fillAlpha);
|
||||||
|
|
||||||
|
this.asteroidGraphics.beginPath();
|
||||||
|
this.asteroidGraphics.moveTo(vertices[0].x, vertices[0].y);
|
||||||
|
for (let i = 1; i < vertices.length; i++) {
|
||||||
|
this.asteroidGraphics.lineTo(vertices[i].x, vertices[i].y);
|
||||||
|
}
|
||||||
|
this.asteroidGraphics.closePath();
|
||||||
|
this.asteroidGraphics.fillPath();
|
||||||
|
this.asteroidGraphics.strokePath();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawBullets() {
|
||||||
|
if (!this.sim) return;
|
||||||
|
|
||||||
|
this.bulletGraphics.fillStyle(0xffff00, 1);
|
||||||
|
|
||||||
|
for (const bullet of this.sim.bullets) {
|
||||||
|
const { position } = bullet.body;
|
||||||
|
this.bulletGraphics.fillCircle(position.x, position.y, 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawDebug() {
|
||||||
|
if (!this.sim) return;
|
||||||
|
|
||||||
|
// Draw raycasts using actual raycast data from simulation
|
||||||
|
const { position } = this.sim.ship;
|
||||||
|
|
||||||
|
for (const raycast of this.sim.lastRaycasts) {
|
||||||
|
const endX = position.x + Math.cos(raycast.angle) * raycast.distance * CONFIG.RAYCAST_LENGTH_MULTIPLIER;
|
||||||
|
const endY = position.y + Math.sin(raycast.angle) * raycast.distance * CONFIG.RAYCAST_LENGTH_MULTIPLIER;
|
||||||
|
|
||||||
|
this.debugGraphics.lineStyle(1, CONFIG.RAYCAST_COLOR, CONFIG.RAYCAST_ALPHA);
|
||||||
|
this.debugGraphics.lineBetween(position.x, position.y, endX, endY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createExplosion(x: number, y: number, tint: number, count: number) {
|
||||||
|
const emitter = this.add.particles(x, y, 'particle', {
|
||||||
|
speed: { min: 50, max: 200 },
|
||||||
|
scale: { start: 1, end: 0 },
|
||||||
|
alpha: { start: 1, end: 0 },
|
||||||
|
lifespan: 600,
|
||||||
|
blendMode: 'ADD',
|
||||||
|
tint: [tint, 0xff8800, 0xffff00],
|
||||||
|
quantity: count,
|
||||||
|
emitting: false
|
||||||
|
});
|
||||||
|
|
||||||
|
emitter.explode(count);
|
||||||
|
|
||||||
|
// Clean up after animation
|
||||||
|
this.time.delayedCall(1000, () => {
|
||||||
|
emitter.destroy();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public startMatch(genomeData: { weights: number[] }, generation: number) {
|
||||||
|
this.generation = generation;
|
||||||
|
|
||||||
|
// Create new simulation
|
||||||
|
this.sim = new AsteroidsSimulation(generation);
|
||||||
|
|
||||||
|
// Create network from genome using config
|
||||||
|
const weights = new Float32Array(genomeData.weights);
|
||||||
|
this.network = new DenseNetwork(getLayerSizes(), weights);
|
||||||
|
|
||||||
|
// Stop thruster particles
|
||||||
|
this.thrusterEmitter.emitting = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public toggleDebug() {
|
||||||
|
this.showDebug = !this.showDebug;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAsteroidsViewer(parent: HTMLElement): Phaser.Game {
|
||||||
|
const config: Phaser.Types.Core.GameConfig = {
|
||||||
|
type: Phaser.AUTO,
|
||||||
|
width: WORLD_WIDTH,
|
||||||
|
height: WORLD_HEIGHT,
|
||||||
|
parent: parent,
|
||||||
|
scene: AsteroidsScene,
|
||||||
|
physics: {
|
||||||
|
default: 'matter',
|
||||||
|
matter: {
|
||||||
|
gravity: { x: 0, y: 0 },
|
||||||
|
debug: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
backgroundColor: '#0a0a15'
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Phaser.Game(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAsteroidsScene(game: Phaser.Game): AsteroidsScene | null {
|
||||||
|
return game.scene.getScene('AsteroidsScene') as AsteroidsScene;
|
||||||
|
}
|
||||||
420
src/apps/AsteroidsAI/AsteroidsSimulation.ts
Normal file
420
src/apps/AsteroidsAI/AsteroidsSimulation.ts
Normal file
@@ -0,0 +1,420 @@
|
|||||||
|
import Matter from 'matter-js';
|
||||||
|
import { CONFIG } from './config';
|
||||||
|
|
||||||
|
export const WORLD_WIDTH = CONFIG.WORLD_WIDTH;
|
||||||
|
export const WORLD_HEIGHT = CONFIG.WORLD_HEIGHT;
|
||||||
|
|
||||||
|
type AsteroidSize = 'large' | 'medium' | 'small';
|
||||||
|
|
||||||
|
interface Bullet {
|
||||||
|
body: Matter.Body;
|
||||||
|
lifetime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Asteroid {
|
||||||
|
body: Matter.Body;
|
||||||
|
size: AsteroidSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AsteroidsSimulation {
|
||||||
|
public engine: Matter.Engine;
|
||||||
|
public ship!: Matter.Body;
|
||||||
|
public bullets: Bullet[] = [];
|
||||||
|
public asteroids: Asteroid[] = [];
|
||||||
|
public lastRaycasts: { angle: number; distance: number }[] = [];
|
||||||
|
public detectedAsteroids: Set<Matter.Body> = new Set(); // Track which asteroids are detected
|
||||||
|
|
||||||
|
public isGameOver = false;
|
||||||
|
public score = 0;
|
||||||
|
public timeSteps = 0;
|
||||||
|
public readonly maxTimeSteps = CONFIG.MAX_TIME_STEPS;
|
||||||
|
public shotsFired = 0;
|
||||||
|
public shotsHit = 0;
|
||||||
|
public asteroidsDestroyed = 0;
|
||||||
|
public totalDistanceTraveled = 0; // Track movement
|
||||||
|
|
||||||
|
private lastShootTime = 0;
|
||||||
|
private readonly shootCooldown = CONFIG.SHOOT_COOLDOWN;
|
||||||
|
private readonly seed: number;
|
||||||
|
private lastPosition = { x: 0, y: 0 }; // For distance tracking
|
||||||
|
|
||||||
|
constructor(seed: number = 0) {
|
||||||
|
this.seed = seed;
|
||||||
|
this.engine = Matter.Engine.create({ enableSleeping: false });
|
||||||
|
this.engine.gravity.y = 0; // Space has no gravity
|
||||||
|
|
||||||
|
// Custom PRNG
|
||||||
|
let s = seed;
|
||||||
|
const random = () => {
|
||||||
|
s = (s * 9301 + 49297) % 233280;
|
||||||
|
return s / 233280;
|
||||||
|
};
|
||||||
|
|
||||||
|
this.setupWorld(random);
|
||||||
|
Matter.Events.on(this.engine, 'collisionStart', (e) => this.handleCollisions(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupWorld(random: () => number) {
|
||||||
|
// Create ship at center
|
||||||
|
const shipVertices = [
|
||||||
|
{ x: 0, y: -CONFIG.SHIP_SIZE },
|
||||||
|
{ x: -CONFIG.SHIP_SIZE * 0.6, y: CONFIG.SHIP_SIZE },
|
||||||
|
{ x: CONFIG.SHIP_SIZE * 0.6, y: CONFIG.SHIP_SIZE }
|
||||||
|
];
|
||||||
|
|
||||||
|
this.ship = Matter.Bodies.fromVertices(
|
||||||
|
WORLD_WIDTH / 2,
|
||||||
|
WORLD_HEIGHT / 2,
|
||||||
|
[shipVertices],
|
||||||
|
{
|
||||||
|
friction: 0,
|
||||||
|
frictionAir: CONFIG.SHIP_FRICTION_AIR,
|
||||||
|
restitution: 0,
|
||||||
|
label: 'ship',
|
||||||
|
angle: -Math.PI / 2
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Matter.Body.setMass(this.ship, CONFIG.SHIP_MASS);
|
||||||
|
Matter.World.add(this.engine.world, [this.ship]);
|
||||||
|
|
||||||
|
// Spawn initial asteroids
|
||||||
|
this.spawnInitialAsteroids(random);
|
||||||
|
}
|
||||||
|
|
||||||
|
private spawnInitialAsteroids(random: () => number) {
|
||||||
|
const numAsteroids = CONFIG.ASTEROID_INITIAL_COUNT;
|
||||||
|
|
||||||
|
for (let i = 0; i < numAsteroids; i++) {
|
||||||
|
// Spawn at edges, away from center
|
||||||
|
const angle = (i / numAsteroids) * Math.PI * 2;
|
||||||
|
const distance = Math.max(WORLD_WIDTH, WORLD_HEIGHT) / 2 + CONFIG.ASTEROID_SPAWN_DISTANCE;
|
||||||
|
const x = WORLD_WIDTH / 2 + Math.cos(angle) * distance;
|
||||||
|
const y = WORLD_HEIGHT / 2 + Math.sin(angle) * distance;
|
||||||
|
|
||||||
|
this.spawnAsteroid(x, y, 'large', random);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private spawnAsteroid(x: number, y: number, size: AsteroidSize, random: () => number) {
|
||||||
|
const radius = size === 'large' ? CONFIG.ASTEROID_SIZE_LARGE :
|
||||||
|
size === 'medium' ? CONFIG.ASTEROID_SIZE_MEDIUM :
|
||||||
|
CONFIG.ASTEROID_SIZE_SMALL;
|
||||||
|
|
||||||
|
// Create irregular asteroid shape
|
||||||
|
const sides = 8 + Math.floor(random() * 4);
|
||||||
|
const vertices = [];
|
||||||
|
for (let i = 0; i < sides; i++) {
|
||||||
|
const angle = (i / sides) * Math.PI * 2;
|
||||||
|
const r = radius * (0.7 + random() * 0.3);
|
||||||
|
vertices.push({
|
||||||
|
x: Math.cos(angle) * r,
|
||||||
|
y: Math.sin(angle) * r
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = Matter.Bodies.fromVertices(x, y, [vertices], {
|
||||||
|
friction: 0,
|
||||||
|
frictionAir: 0,
|
||||||
|
restitution: 1,
|
||||||
|
label: 'asteroid'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Asteroid speeds from config
|
||||||
|
const speed = size === 'large' ? CONFIG.ASTEROID_SPEED_LARGE :
|
||||||
|
size === 'medium' ? CONFIG.ASTEROID_SPEED_MEDIUM :
|
||||||
|
CONFIG.ASTEROID_SPEED_SMALL;
|
||||||
|
|
||||||
|
// ANTI-CAMPING: Make asteroids move AWAY from center
|
||||||
|
// This forces the AI to chase them instead of camping
|
||||||
|
const centerX = WORLD_WIDTH / 2;
|
||||||
|
const centerY = WORLD_HEIGHT / 2;
|
||||||
|
const angleFromCenter = Math.atan2(y - centerY, x - centerX);
|
||||||
|
|
||||||
|
// Add some randomness but bias away from center
|
||||||
|
const randomOffset = (random() - 0.5) * Math.PI * 0.5; // ±45 degrees
|
||||||
|
const vAngle = angleFromCenter + randomOffset;
|
||||||
|
|
||||||
|
Matter.Body.setVelocity(body, {
|
||||||
|
x: Math.cos(vAngle) * speed,
|
||||||
|
y: Math.sin(vAngle) * speed
|
||||||
|
});
|
||||||
|
Matter.Body.setAngularVelocity(body, (random() - 0.5) * CONFIG.ASTEROID_ANGULAR_VELOCITY);
|
||||||
|
|
||||||
|
this.asteroids.push({ body, size });
|
||||||
|
Matter.World.add(this.engine.world, [body]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleCollisions(event: Matter.IEventCollision<Matter.Engine>) {
|
||||||
|
if (this.isGameOver) return;
|
||||||
|
|
||||||
|
event.pairs.forEach(pair => {
|
||||||
|
const { bodyA, bodyB } = pair;
|
||||||
|
|
||||||
|
// Ship hit asteroid
|
||||||
|
if ((bodyA === this.ship && bodyB.label === 'asteroid') ||
|
||||||
|
(bodyB === this.ship && bodyA.label === 'asteroid')) {
|
||||||
|
this.isGameOver = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bullet hit asteroid
|
||||||
|
const bullet = this.bullets.find(b => b.body === bodyA || b.body === bodyB);
|
||||||
|
const asteroidHit = this.asteroids.find(a => a.body === bodyA || a.body === bodyB);
|
||||||
|
|
||||||
|
if (bullet && asteroidHit) {
|
||||||
|
this.handleAsteroidHit(asteroidHit, bullet);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleAsteroidHit(asteroid: Asteroid, bullet: Bullet) {
|
||||||
|
this.shotsHit++;
|
||||||
|
this.asteroidsDestroyed++;
|
||||||
|
|
||||||
|
// Remove bullet
|
||||||
|
Matter.World.remove(this.engine.world, bullet.body);
|
||||||
|
this.bullets = this.bullets.filter(b => b !== bullet);
|
||||||
|
|
||||||
|
// Score based on size from config
|
||||||
|
const points = asteroid.size === 'large' ? CONFIG.SCORE_LARGE :
|
||||||
|
asteroid.size === 'medium' ? CONFIG.SCORE_MEDIUM :
|
||||||
|
CONFIG.SCORE_SMALL;
|
||||||
|
this.score += points;
|
||||||
|
|
||||||
|
// Split asteroid
|
||||||
|
const pos = asteroid.body.position;
|
||||||
|
Matter.World.remove(this.engine.world, asteroid.body);
|
||||||
|
this.asteroids = this.asteroids.filter(a => a !== asteroid);
|
||||||
|
|
||||||
|
// Custom PRNG for splitting
|
||||||
|
let s = this.seed + this.timeSteps;
|
||||||
|
const random = () => {
|
||||||
|
s = (s * 9301 + 49297) % 233280;
|
||||||
|
return s / 233280;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (asteroid.size === 'large') {
|
||||||
|
// Split into 3 medium
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const angle = (i / 3) * Math.PI * 2;
|
||||||
|
const offset = 30;
|
||||||
|
this.spawnAsteroid(
|
||||||
|
pos.x + Math.cos(angle) * offset,
|
||||||
|
pos.y + Math.sin(angle) * offset,
|
||||||
|
'medium',
|
||||||
|
random
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (asteroid.size === 'medium') {
|
||||||
|
// Split into 2 small
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
const angle = (i / 2) * Math.PI * 2;
|
||||||
|
const offset = 20;
|
||||||
|
this.spawnAsteroid(
|
||||||
|
pos.x + Math.cos(angle) * offset,
|
||||||
|
pos.y + Math.sin(angle) * offset,
|
||||||
|
'small',
|
||||||
|
random
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Small asteroids just disappear
|
||||||
|
}
|
||||||
|
|
||||||
|
public update(actions: number[]): boolean {
|
||||||
|
if (this.isGameOver) return false;
|
||||||
|
|
||||||
|
// Track distance traveled
|
||||||
|
const dx = this.ship.position.x - this.lastPosition.x;
|
||||||
|
const dy = this.ship.position.y - this.lastPosition.y;
|
||||||
|
this.totalDistanceTraveled += Math.sqrt(dx * dx + dy * dy);
|
||||||
|
this.lastPosition = { x: this.ship.position.x, y: this.ship.position.y };
|
||||||
|
|
||||||
|
// Apply AI controls.maxTimeSteps) {
|
||||||
|
if (++this.timeSteps > this.maxTimeSteps) {
|
||||||
|
this.isGameOver = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions: [rotation (-1 to 1), thrust (0 to 1), shoot (0 to 1)]
|
||||||
|
const rotation = actions[0];
|
||||||
|
const thrust = Math.max(0, Math.min(1, (actions[1] + 1) / 2));
|
||||||
|
const shoot = actions[2] > 0;
|
||||||
|
|
||||||
|
this.applyControls(rotation, thrust, shoot);
|
||||||
|
this.updateBullets();
|
||||||
|
this.wrapBodies();
|
||||||
|
|
||||||
|
Matter.Engine.update(this.engine, 1000 / 60);
|
||||||
|
|
||||||
|
// Check if all asteroids destroyed (respawn)
|
||||||
|
if (this.asteroids.length === 0) {
|
||||||
|
let s = this.seed + this.timeSteps;
|
||||||
|
const random = () => {
|
||||||
|
s = (s * 9301 + 49297) % 233280;
|
||||||
|
return s / 233280;
|
||||||
|
};
|
||||||
|
this.spawnInitialAsteroids(random);
|
||||||
|
}
|
||||||
|
|
||||||
|
return !this.isGameOver;
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyControls(rotation: number, thrust: number, shoot: boolean) {
|
||||||
|
// Rotation from config
|
||||||
|
Matter.Body.setAngularVelocity(this.ship, rotation * CONFIG.ROTATION_SPEED);
|
||||||
|
|
||||||
|
// Thrust from config
|
||||||
|
if (thrust > 0.1) {
|
||||||
|
const angle = this.ship.angle;
|
||||||
|
Matter.Body.applyForce(this.ship, this.ship.position, {
|
||||||
|
x: Math.cos(angle) * CONFIG.THRUST_FORCE * thrust,
|
||||||
|
y: Math.sin(angle) * CONFIG.THRUST_FORCE * thrust
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shoot
|
||||||
|
if (shoot && this.timeSteps - this.lastShootTime >= this.shootCooldown) {
|
||||||
|
this.shootBullet();
|
||||||
|
this.lastShootTime = this.timeSteps;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private shootBullet() {
|
||||||
|
this.shotsFired++;
|
||||||
|
|
||||||
|
const angle = this.ship.angle;
|
||||||
|
const offset = CONFIG.SHIP_SIZE;
|
||||||
|
const pos = {
|
||||||
|
x: this.ship.position.x + Math.cos(angle) * offset,
|
||||||
|
y: this.ship.position.y + Math.sin(angle) * offset
|
||||||
|
};
|
||||||
|
|
||||||
|
const bullet = Matter.Bodies.circle(pos.x, pos.y, CONFIG.BULLET_RADIUS, {
|
||||||
|
friction: 0,
|
||||||
|
frictionAir: 0,
|
||||||
|
restitution: 0,
|
||||||
|
label: 'bullet',
|
||||||
|
isSensor: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const velocity = {
|
||||||
|
x: this.ship.velocity.x + Math.cos(angle) * CONFIG.BULLET_SPEED,
|
||||||
|
y: this.ship.velocity.y + Math.sin(angle) * CONFIG.BULLET_SPEED
|
||||||
|
};
|
||||||
|
Matter.Body.setVelocity(bullet, velocity);
|
||||||
|
|
||||||
|
this.bullets.push({ body: bullet, lifetime: CONFIG.BULLET_LIFETIME });
|
||||||
|
Matter.World.add(this.engine.world, [bullet]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateBullets() {
|
||||||
|
for (let i = this.bullets.length - 1; i >= 0; i--) {
|
||||||
|
this.bullets[i].lifetime--;
|
||||||
|
if (this.bullets[i].lifetime <= 0) {
|
||||||
|
Matter.World.remove(this.engine.world, this.bullets[i].body);
|
||||||
|
this.bullets.splice(i, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private wrapBodies() {
|
||||||
|
const wrap = (body: Matter.Body) => {
|
||||||
|
const pos = body.position;
|
||||||
|
let wrapped = false;
|
||||||
|
|
||||||
|
if (pos.x < 0) {
|
||||||
|
Matter.Body.setPosition(body, { x: WORLD_WIDTH, y: pos.y });
|
||||||
|
wrapped = true;
|
||||||
|
} else if (pos.x > WORLD_WIDTH) {
|
||||||
|
Matter.Body.setPosition(body, { x: 0, y: pos.y });
|
||||||
|
wrapped = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pos.y < 0) {
|
||||||
|
Matter.Body.setPosition(body, { x: pos.x, y: WORLD_HEIGHT });
|
||||||
|
wrapped = true;
|
||||||
|
} else if (pos.y > WORLD_HEIGHT) {
|
||||||
|
Matter.Body.setPosition(body, { x: pos.x, y: 0 });
|
||||||
|
wrapped = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return wrapped;
|
||||||
|
};
|
||||||
|
|
||||||
|
wrap(this.ship);
|
||||||
|
this.asteroids.forEach(a => wrap(a.body));
|
||||||
|
this.bullets.forEach(b => wrap(b.body));
|
||||||
|
}
|
||||||
|
|
||||||
|
public getObservation(): number[] {
|
||||||
|
// Raycasts for asteroid detection (configurable count)
|
||||||
|
const raycasts = this.getRaycasts();
|
||||||
|
|
||||||
|
// Ship state
|
||||||
|
const { velocity, angularVelocity, angle } = this.ship;
|
||||||
|
|
||||||
|
return [
|
||||||
|
...raycasts, // distance to nearest asteroid in each direction
|
||||||
|
velocity.x / 10,
|
||||||
|
velocity.y / 10,
|
||||||
|
angularVelocity / 0.5,
|
||||||
|
Math.sin(angle), // Encode angle as sin/cos for continuity
|
||||||
|
Math.cos(angle)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private getRaycasts(): number[] {
|
||||||
|
const numRays = CONFIG.NUM_RAYCASTS;
|
||||||
|
const maxDistance = Math.max(WORLD_WIDTH, WORLD_HEIGHT);
|
||||||
|
const results: number[] = [];
|
||||||
|
this.lastRaycasts = []; // Store for visualization
|
||||||
|
this.detectedAsteroids.clear(); // Clear previous detections
|
||||||
|
|
||||||
|
for (let i = 0; i < numRays; i++) {
|
||||||
|
const rayAngle = this.ship.angle + (i / numRays) * Math.PI * 2;
|
||||||
|
let minDist = 1.0; // Normalized (1.0 = no asteroid detected)
|
||||||
|
let closestAsteroid: Matter.Body | null = null;
|
||||||
|
|
||||||
|
// Check distance to each asteroid
|
||||||
|
for (const asteroid of this.asteroids) {
|
||||||
|
const dx = asteroid.body.position.x - this.ship.position.x;
|
||||||
|
const dy = asteroid.body.position.y - this.ship.position.y;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
// Check if asteroid is in this ray's direction
|
||||||
|
const asteroidAngle = Math.atan2(dy, dx);
|
||||||
|
let angleDiff = asteroidAngle - rayAngle;
|
||||||
|
|
||||||
|
// Normalize angle difference to [-PI, PI]
|
||||||
|
while (angleDiff > Math.PI) angleDiff -= Math.PI * 2;
|
||||||
|
while (angleDiff < -Math.PI) angleDiff += Math.PI * 2;
|
||||||
|
|
||||||
|
// If within ray cone
|
||||||
|
if (Math.abs(angleDiff) < Math.PI / numRays) {
|
||||||
|
const normalizedDist = Math.min(1.0, dist / maxDistance);
|
||||||
|
if (normalizedDist < minDist) {
|
||||||
|
minDist = normalizedDist;
|
||||||
|
closestAsteroid = asteroid.body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark detected asteroid
|
||||||
|
if (closestAsteroid) {
|
||||||
|
this.detectedAsteroids.add(closestAsteroid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store raycast data for visualization
|
||||||
|
this.lastRaycasts.push({
|
||||||
|
angle: rayAngle,
|
||||||
|
distance: minDist * maxDistance
|
||||||
|
});
|
||||||
|
|
||||||
|
results.push(minDist);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
}
|
||||||
127
src/apps/AsteroidsAI/ConfigPanel.css
Normal file
127
src/apps/AsteroidsAI/ConfigPanel.css
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
.config-panel {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-toggle {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
text-align: left;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-toggle:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-content {
|
||||||
|
margin-top: 15px;
|
||||||
|
padding: 20px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 12px;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
max-height: 600px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-section {
|
||||||
|
margin-bottom: 25px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-section:last-of-type {
|
||||||
|
border-bottom: none;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-section h3 {
|
||||||
|
margin: 0 0 15px 0;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item label {
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
font-size: 14px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item input[type="number"] {
|
||||||
|
width: 100px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: white;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item input[type="number"]:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border-color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item input[type="number"]:focus {
|
||||||
|
outline: none;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-color: #667eea;
|
||||||
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-value {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-note {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 12px;
|
||||||
|
background: rgba(255, 193, 7, 0.1);
|
||||||
|
border-left: 3px solid #ffc107;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #ffc107;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling */
|
||||||
|
.config-content::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-content::-webkit-scrollbar-track {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-content::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-content::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
265
src/apps/AsteroidsAI/ConfigPanel.tsx
Normal file
265
src/apps/AsteroidsAI/ConfigPanel.tsx
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { CONFIG, updateConfig } from './config';
|
||||||
|
import './ConfigPanel.css';
|
||||||
|
|
||||||
|
interface ConfigPanelProps {
|
||||||
|
onConfigChange?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConfigPanel({ onConfigChange }: ConfigPanelProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [, forceUpdate] = useState({});
|
||||||
|
|
||||||
|
const handleChange = (key: keyof typeof CONFIG, value: number | number[]) => {
|
||||||
|
updateConfig({ [key]: value });
|
||||||
|
forceUpdate({}); // Force re-render to show updated values
|
||||||
|
if (onConfigChange) {
|
||||||
|
onConfigChange();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="config-panel">
|
||||||
|
<button
|
||||||
|
className="config-toggle"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
>
|
||||||
|
⚙️ Configuration {isOpen ? '▼' : '▶'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="config-content">
|
||||||
|
<div className="config-section">
|
||||||
|
<h3>🧠 Neural Network</h3>
|
||||||
|
<div className="config-item">
|
||||||
|
<label>Raycasts:</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={CONFIG.NUM_RAYCASTS}
|
||||||
|
onChange={(e) => handleChange('NUM_RAYCASTS', parseInt(e.target.value))}
|
||||||
|
min="4" max="32" step="4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="config-item">
|
||||||
|
<label>Hidden Layers:</label>
|
||||||
|
<span className="config-value">[{CONFIG.HIDDEN_LAYERS.join(', ')}]</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="config-section">
|
||||||
|
<h3>🧬 Genetic Algorithm</h3>
|
||||||
|
<div className="config-item">
|
||||||
|
<label>Population Size:</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={CONFIG.POPULATION_SIZE}
|
||||||
|
onChange={(e) => handleChange('POPULATION_SIZE', parseInt(e.target.value))}
|
||||||
|
min="10" max="500" step="10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="config-item">
|
||||||
|
<label>Mutation Rate:</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={CONFIG.MUTATION_RATE}
|
||||||
|
onChange={(e) => handleChange('MUTATION_RATE', parseFloat(e.target.value))}
|
||||||
|
min="0" max="1" step="0.05"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="config-item">
|
||||||
|
<label>Mutation Scale:</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={CONFIG.MUTATION_SCALE}
|
||||||
|
onChange={(e) => handleChange('MUTATION_SCALE', parseFloat(e.target.value))}
|
||||||
|
min="0" max="2" step="0.1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="config-item">
|
||||||
|
<label>Elite Count:</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={CONFIG.ELITE_COUNT}
|
||||||
|
onChange={(e) => handleChange('ELITE_COUNT', parseInt(e.target.value))}
|
||||||
|
min="0" max="20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="config-item">
|
||||||
|
<label>Scenarios per Genome:</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={CONFIG.SCENARIOS_PER_GENOME}
|
||||||
|
onChange={(e) => handleChange('SCENARIOS_PER_GENOME', parseInt(e.target.value))}
|
||||||
|
min="1" max="10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="config-section">
|
||||||
|
<h3>🚀 Ship Properties</h3>
|
||||||
|
<div className="config-item">
|
||||||
|
<label>Ship Size:</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={CONFIG.SHIP_SIZE}
|
||||||
|
onChange={(e) => handleChange('SHIP_SIZE', parseInt(e.target.value))}
|
||||||
|
min="5" max="30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="config-item">
|
||||||
|
<label>Rotation Speed:</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={CONFIG.ROTATION_SPEED}
|
||||||
|
onChange={(e) => handleChange('ROTATION_SPEED', parseFloat(e.target.value))}
|
||||||
|
min="0.01" max="0.5" step="0.01"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="config-item">
|
||||||
|
<label>Thrust Force:</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={CONFIG.THRUST_FORCE}
|
||||||
|
onChange={(e) => handleChange('THRUST_FORCE', parseFloat(e.target.value))}
|
||||||
|
min="0.0001" max="0.002" step="0.0001"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="config-section">
|
||||||
|
<h3>💥 Bullet Properties</h3>
|
||||||
|
<div className="config-item">
|
||||||
|
<label>Bullet Speed:</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={CONFIG.BULLET_SPEED}
|
||||||
|
onChange={(e) => handleChange('BULLET_SPEED', parseInt(e.target.value))}
|
||||||
|
min="1" max="30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="config-item">
|
||||||
|
<label>Bullet Lifetime (frames):</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={CONFIG.BULLET_LIFETIME}
|
||||||
|
onChange={(e) => handleChange('BULLET_LIFETIME', parseInt(e.target.value))}
|
||||||
|
min="10" max="120" step="10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="config-item">
|
||||||
|
<label>Shoot Cooldown (frames):</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={CONFIG.SHOOT_COOLDOWN}
|
||||||
|
onChange={(e) => handleChange('SHOOT_COOLDOWN', parseInt(e.target.value))}
|
||||||
|
min="1" max="30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="config-section">
|
||||||
|
<h3>☄️ Asteroid Properties</h3>
|
||||||
|
<div className="config-item">
|
||||||
|
<label>Initial Count:</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={CONFIG.ASTEROID_INITIAL_COUNT}
|
||||||
|
onChange={(e) => handleChange('ASTEROID_INITIAL_COUNT', parseInt(e.target.value))}
|
||||||
|
min="1" max="10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="config-item">
|
||||||
|
<label>Large Size:</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={CONFIG.ASTEROID_SIZE_LARGE}
|
||||||
|
onChange={(e) => handleChange('ASTEROID_SIZE_LARGE', parseInt(e.target.value))}
|
||||||
|
min="20" max="100" step="5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="config-item">
|
||||||
|
<label>Medium Size:</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={CONFIG.ASTEROID_SIZE_MEDIUM}
|
||||||
|
onChange={(e) => handleChange('ASTEROID_SIZE_MEDIUM', parseInt(e.target.value))}
|
||||||
|
min="10" max="60" step="5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="config-item">
|
||||||
|
<label>Small Size:</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={CONFIG.ASTEROID_SIZE_SMALL}
|
||||||
|
onChange={(e) => handleChange('ASTEROID_SIZE_SMALL', parseInt(e.target.value))}
|
||||||
|
min="5" max="40" step="2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="config-item">
|
||||||
|
<label>Large Speed:</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={CONFIG.ASTEROID_SPEED_LARGE}
|
||||||
|
onChange={(e) => handleChange('ASTEROID_SPEED_LARGE', parseFloat(e.target.value))}
|
||||||
|
min="0.1" max="3" step="0.1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="config-item">
|
||||||
|
<label>Medium Speed:</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={CONFIG.ASTEROID_SPEED_MEDIUM}
|
||||||
|
onChange={(e) => handleChange('ASTEROID_SPEED_MEDIUM', parseFloat(e.target.value))}
|
||||||
|
min="0.1" max="4" step="0.1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="config-item">
|
||||||
|
<label>Small Speed:</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={CONFIG.ASTEROID_SPEED_SMALL}
|
||||||
|
onChange={(e) => handleChange('ASTEROID_SPEED_SMALL', parseFloat(e.target.value))}
|
||||||
|
min="0.1" max="5" step="0.1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="config-section">
|
||||||
|
<h3>🎯 Scoring</h3>
|
||||||
|
<div className="config-item">
|
||||||
|
<label>Large Asteroid:</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={CONFIG.SCORE_LARGE}
|
||||||
|
onChange={(e) => handleChange('SCORE_LARGE', parseInt(e.target.value))}
|
||||||
|
min="1" max="100" step="5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="config-item">
|
||||||
|
<label>Medium Asteroid:</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={CONFIG.SCORE_MEDIUM}
|
||||||
|
onChange={(e) => handleChange('SCORE_MEDIUM', parseInt(e.target.value))}
|
||||||
|
min="1" max="200" step="10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="config-item">
|
||||||
|
<label>Small Asteroid:</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={CONFIG.SCORE_SMALL}
|
||||||
|
onChange={(e) => handleChange('SCORE_SMALL', parseInt(e.target.value))}
|
||||||
|
min="1" max="300" step="10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="config-note">
|
||||||
|
✅ Changes apply immediately! Click Reset to restart training with new config.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
src/apps/AsteroidsAI/DenseNetwork.ts
Normal file
69
src/apps/AsteroidsAI/DenseNetwork.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
|
||||||
|
export class DenseNetwork {
|
||||||
|
private weights: Float32Array;
|
||||||
|
private layerSizes: number[];
|
||||||
|
|
||||||
|
constructor(layerSizes: number[], weights?: Float32Array) {
|
||||||
|
this.layerSizes = layerSizes;
|
||||||
|
const totalWeights = this.calculateTotalWeights(layerSizes);
|
||||||
|
|
||||||
|
if (weights) {
|
||||||
|
if (weights.length !== totalWeights) {
|
||||||
|
throw new Error(`Expected ${totalWeights} weights, got ${weights.length}`);
|
||||||
|
}
|
||||||
|
this.weights = weights;
|
||||||
|
} else {
|
||||||
|
this.weights = new Float32Array(totalWeights);
|
||||||
|
this.randomize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateTotalWeights(sizes: number[]): number {
|
||||||
|
let total = 0;
|
||||||
|
for (let i = 0; i < sizes.length - 1; i++) {
|
||||||
|
// Weights + Bias for each next-layer neuron
|
||||||
|
// (Input + 1) * Output
|
||||||
|
total += (sizes[i] + 1) * sizes[i + 1];
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
private randomize() {
|
||||||
|
for (let i = 0; i < this.weights.length; i++) {
|
||||||
|
this.weights[i] = (Math.random() * 2 - 1); // -1 to 1 simplified initialization
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public predict(inputs: number[]): number[] {
|
||||||
|
let currentValues = inputs;
|
||||||
|
|
||||||
|
let weightIndex = 0;
|
||||||
|
for (let i = 0; i < this.layerSizes.length - 1; i++) {
|
||||||
|
const inputSize = this.layerSizes[i];
|
||||||
|
const outputSize = this.layerSizes[i + 1];
|
||||||
|
const nextValues = new Array(outputSize).fill(0);
|
||||||
|
|
||||||
|
for (let out = 0; out < outputSize; out++) {
|
||||||
|
let sum = 0;
|
||||||
|
// Weights
|
||||||
|
for (let inp = 0; inp < inputSize; inp++) {
|
||||||
|
sum += currentValues[inp] * this.weights[weightIndex++];
|
||||||
|
}
|
||||||
|
// Bias (last weight for this neuron)
|
||||||
|
sum += this.weights[weightIndex++];
|
||||||
|
|
||||||
|
// Activation
|
||||||
|
// Output layer (last layer) -> Tanh for action outputs (-1 to 1)
|
||||||
|
// Hidden layers -> ReLU or Tanh. Let's use Tanh everywhere for simplicity/stability in evolution.
|
||||||
|
nextValues[out] = Math.tanh(sum);
|
||||||
|
}
|
||||||
|
currentValues = nextValues;
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentValues;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getWeights(): Float32Array {
|
||||||
|
return this.weights;
|
||||||
|
}
|
||||||
|
}
|
||||||
118
src/apps/AsteroidsAI/GeneticAlgo.ts
Normal file
118
src/apps/AsteroidsAI/GeneticAlgo.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
|
||||||
|
import { DenseNetwork } from './DenseNetwork';
|
||||||
|
|
||||||
|
export interface Genome {
|
||||||
|
weights: Float32Array;
|
||||||
|
fitness: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GeneticAlgo {
|
||||||
|
private population: Genome[] = [];
|
||||||
|
private popSize: number;
|
||||||
|
private mutationRate: number;
|
||||||
|
private mutationScale: number;
|
||||||
|
public generation = 0;
|
||||||
|
|
||||||
|
// Track best ever
|
||||||
|
public bestGenome: Genome | null = null;
|
||||||
|
public bestFitness = -Infinity;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
popSize: number,
|
||||||
|
layerSizes: number[],
|
||||||
|
mutationRate = 0.1, // Chance per weight (increased for diversity)
|
||||||
|
mutationScale = 0.5 // Gaussian/random perturbation amount (increased)
|
||||||
|
) {
|
||||||
|
this.popSize = popSize;
|
||||||
|
this.mutationRate = mutationRate;
|
||||||
|
this.mutationScale = mutationScale;
|
||||||
|
|
||||||
|
// Init population
|
||||||
|
for (let i = 0; i < popSize; i++) {
|
||||||
|
const net = new DenseNetwork(layerSizes);
|
||||||
|
this.population.push({
|
||||||
|
weights: net.getWeights(), // Actually reference, careful on mutation, should clone on breed
|
||||||
|
fitness: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getPopulation() {
|
||||||
|
return this.population;
|
||||||
|
}
|
||||||
|
|
||||||
|
public evolve() {
|
||||||
|
// 1. Sort by fitness
|
||||||
|
this.population.sort((a, b) => b.fitness - a.fitness);
|
||||||
|
|
||||||
|
// Update best
|
||||||
|
if (this.population[0].fitness > this.bestFitness) {
|
||||||
|
this.bestFitness = this.population[0].fitness;
|
||||||
|
// Clone best weights to save safe
|
||||||
|
this.bestGenome = {
|
||||||
|
weights: new Float32Array(this.population[0].weights),
|
||||||
|
fitness: this.population[0].fitness
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const newPop: Genome[] = [];
|
||||||
|
|
||||||
|
// 2. Elitism (Keep top 5)
|
||||||
|
const ELITE_COUNT = 5;
|
||||||
|
for (let i = 0; i < ELITE_COUNT; i++) {
|
||||||
|
newPop.push({
|
||||||
|
weights: new Float32Array(this.population[i].weights),
|
||||||
|
fitness: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Breed rest
|
||||||
|
while (newPop.length < this.popSize) {
|
||||||
|
// Tournament Select
|
||||||
|
const p1 = this.tournamentSelect();
|
||||||
|
const p2 = this.tournamentSelect();
|
||||||
|
|
||||||
|
// Crossover
|
||||||
|
const childWeights = this.crossover(p1.weights, p2.weights);
|
||||||
|
|
||||||
|
// Mutate
|
||||||
|
this.mutate(childWeights);
|
||||||
|
|
||||||
|
newPop.push({
|
||||||
|
weights: childWeights,
|
||||||
|
fitness: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.population = newPop;
|
||||||
|
this.generation++;
|
||||||
|
}
|
||||||
|
|
||||||
|
private tournamentSelect(): Genome {
|
||||||
|
const pool = 5;
|
||||||
|
let best = this.population[Math.floor(Math.random() * this.population.length)];
|
||||||
|
for (let i = 0; i < pool - 1; i++) {
|
||||||
|
const cand = this.population[Math.floor(Math.random() * this.population.length)];
|
||||||
|
if (cand.fitness > best.fitness) best = cand;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
private crossover(w1: Float32Array, w2: Float32Array): Float32Array {
|
||||||
|
const child = new Float32Array(w1.length);
|
||||||
|
// Uniform crossover
|
||||||
|
for (let i = 0; i < w1.length; i++) {
|
||||||
|
child[i] = Math.random() < 0.5 ? w1[i] : w2[i];
|
||||||
|
}
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutate(weights: Float32Array) {
|
||||||
|
for (let i = 0; i < weights.length; i++) {
|
||||||
|
if (Math.random() < this.mutationRate) {
|
||||||
|
// Add noise
|
||||||
|
weights[i] += (Math.random() * 2 - 1) * this.mutationScale;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/apps/AsteroidsAI/config.ts
Normal file
86
src/apps/AsteroidsAI/config.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
// Configuration for Asteroids AI
|
||||||
|
// Mutable config that can be updated at runtime
|
||||||
|
|
||||||
|
export let CONFIG = {
|
||||||
|
// Neural Network Architecture
|
||||||
|
NUM_RAYCASTS: 16,
|
||||||
|
HIDDEN_LAYERS: [24, 24],
|
||||||
|
|
||||||
|
// Genetic Algorithm
|
||||||
|
POPULATION_SIZE: 150,
|
||||||
|
MUTATION_RATE: 0.25,
|
||||||
|
MUTATION_SCALE: 1.0,
|
||||||
|
ELITE_COUNT: 3,
|
||||||
|
|
||||||
|
// Training
|
||||||
|
SCENARIOS_PER_GENOME: 5,
|
||||||
|
|
||||||
|
// World Settings
|
||||||
|
WORLD_WIDTH: 800,
|
||||||
|
WORLD_HEIGHT: 600,
|
||||||
|
MAX_TIME_STEPS: 60 * 60, // 60 seconds
|
||||||
|
|
||||||
|
// Ship Properties
|
||||||
|
SHIP_SIZE: 15,
|
||||||
|
SHIP_FRICTION_AIR: 0.02,
|
||||||
|
SHIP_MASS: 1,
|
||||||
|
ROTATION_SPEED: 0.10,
|
||||||
|
THRUST_FORCE: 0.0005,
|
||||||
|
|
||||||
|
// Bullet Properties
|
||||||
|
BULLET_SPEED: 12,
|
||||||
|
BULLET_LIFETIME: 30, // frames
|
||||||
|
BULLET_RADIUS: 2,
|
||||||
|
SHOOT_COOLDOWN: 12, // frames between shots
|
||||||
|
|
||||||
|
// Asteroid Properties
|
||||||
|
ASTEROID_SPAWN_DISTANCE: 100,
|
||||||
|
ASTEROID_INITIAL_COUNT: 4,
|
||||||
|
ASTEROID_SIZE_LARGE: 50,
|
||||||
|
ASTEROID_SIZE_MEDIUM: 30,
|
||||||
|
ASTEROID_SIZE_SMALL: 18,
|
||||||
|
ASTEROID_SPEED_LARGE: 0.75,
|
||||||
|
ASTEROID_SPEED_MEDIUM: 1.0,
|
||||||
|
ASTEROID_SPEED_SMALL: 1.5,
|
||||||
|
ASTEROID_ANGULAR_VELOCITY: 0.1,
|
||||||
|
|
||||||
|
// Scoring
|
||||||
|
SCORE_LARGE: 20,
|
||||||
|
SCORE_MEDIUM: 50,
|
||||||
|
SCORE_SMALL: 100,
|
||||||
|
|
||||||
|
// Visualization
|
||||||
|
SHOW_RAYCASTS: true,
|
||||||
|
RAYCAST_COLOR: 0x00ff00,
|
||||||
|
RAYCAST_ALPHA: 0.8,
|
||||||
|
RAYCAST_LENGTH_MULTIPLIER: 0.8,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to update config values
|
||||||
|
export function updateConfig(updates: Partial<typeof CONFIG>) {
|
||||||
|
CONFIG = { ...CONFIG, ...updates };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to get current config (for reading)
|
||||||
|
export function getConfig() {
|
||||||
|
return { ...CONFIG };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate total inputs for neural network
|
||||||
|
export function getInputCount(): number {
|
||||||
|
return CONFIG.NUM_RAYCASTS + 5; // raycasts + velocity.x + velocity.y + angularVelocity + sin(angle) + cos(angle)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate total outputs for neural network
|
||||||
|
export function getOutputCount(): number {
|
||||||
|
return 3; // rotation, thrust, shoot
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get layer sizes for neural network
|
||||||
|
export function getLayerSizes(): number[] {
|
||||||
|
return [
|
||||||
|
getInputCount(),
|
||||||
|
...CONFIG.HIDDEN_LAYERS,
|
||||||
|
getOutputCount()
|
||||||
|
];
|
||||||
|
}
|
||||||
69
src/apps/AsteroidsAI/debug.test.ts
Normal file
69
src/apps/AsteroidsAI/debug.test.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
// Quick test to verify the simulation and fitness work correctly
|
||||||
|
import { AsteroidsSimulation } from './AsteroidsSimulation';
|
||||||
|
import { calculateFitness } from './fitnessConfig';
|
||||||
|
import { DenseNetwork } from './DenseNetwork';
|
||||||
|
import { getLayerSizes } from './config';
|
||||||
|
|
||||||
|
console.log('=== ASTEROIDS AI DEBUG TEST ===');
|
||||||
|
|
||||||
|
// Test 1: Does the simulation run?
|
||||||
|
console.log('\n1. Testing basic simulation...');
|
||||||
|
const sim1 = new AsteroidsSimulation(0);
|
||||||
|
let steps = 0;
|
||||||
|
while (!sim1.isGameOver && steps < 100) {
|
||||||
|
sim1.update([0, 0, 0]); // No actions
|
||||||
|
steps++;
|
||||||
|
}
|
||||||
|
console.log(`Simulation ran for ${steps} steps`);
|
||||||
|
console.log(`Game over: ${sim1.isGameOver}`);
|
||||||
|
console.log(`Asteroids: ${sim1.asteroids.length}`);
|
||||||
|
|
||||||
|
// Test 2: Does shooting work?
|
||||||
|
console.log('\n2. Testing shooting...');
|
||||||
|
const sim2 = new AsteroidsSimulation(0);
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
sim2.update([0, 0, 1]); // Always shoot
|
||||||
|
}
|
||||||
|
console.log(`Shots fired: ${sim2.shotsFired}`);
|
||||||
|
console.log(`Bullets: ${sim2.bullets.length}`);
|
||||||
|
|
||||||
|
// Test 3: Can we destroy asteroids?
|
||||||
|
console.log('\n3. Testing asteroid destruction...');
|
||||||
|
const sim3 = new AsteroidsSimulation(0);
|
||||||
|
for (let i = 0; i < 300; i++) {
|
||||||
|
// Rotate and shoot
|
||||||
|
sim3.update([0.5, 0, 1]);
|
||||||
|
}
|
||||||
|
console.log(`Asteroids destroyed: ${sim3.asteroidsDestroyed}`);
|
||||||
|
console.log(`Score: ${sim3.score}`);
|
||||||
|
console.log(`Fitness: ${calculateFitness(sim3).toFixed(1)}`);
|
||||||
|
|
||||||
|
// Test 4: Do network outputs make sense?
|
||||||
|
console.log('\n4. Testing neural network...');
|
||||||
|
const network = new DenseNetwork(getLayerSizes());
|
||||||
|
const testInputs = new Array(getLayerSizes()[0]).fill(0.5);
|
||||||
|
const outputs = network.predict(testInputs);
|
||||||
|
console.log(`Network outputs: [${outputs.map(o => o.toFixed(3)).join(', ')}]`);
|
||||||
|
console.log(`Output range: [${Math.min(...outputs).toFixed(3)}, ${Math.max(...outputs).toFixed(3)}]`);
|
||||||
|
|
||||||
|
// Test 5: Fitness for doing nothing vs shooting
|
||||||
|
console.log('\n5. Comparing fitness strategies...');
|
||||||
|
const simNothing = new AsteroidsSimulation(0);
|
||||||
|
for (let i = 0; i < 600; i++) {
|
||||||
|
if (simNothing.isGameOver) break;
|
||||||
|
simNothing.update([0, 0, 0]);
|
||||||
|
}
|
||||||
|
const fitnessNothing = calculateFitness(simNothing);
|
||||||
|
|
||||||
|
const simShooting = new AsteroidsSimulation(0);
|
||||||
|
for (let i = 0; i < 600; i++) {
|
||||||
|
if (simShooting.isGameOver) break;
|
||||||
|
simShooting.update([0, 0, 1]);
|
||||||
|
}
|
||||||
|
const fitnessShooting = calculateFitness(simShooting);
|
||||||
|
|
||||||
|
console.log(`\nDoing nothing: ${fitnessNothing.toFixed(1)} (survived ${simNothing.timeSteps} steps)`);
|
||||||
|
console.log(`Always shooting: ${fitnessShooting.toFixed(1)} (survived ${simShooting.timeSteps} steps, ${simShooting.shotsFired} shots, ${simShooting.asteroidsDestroyed} destroyed)`);
|
||||||
|
console.log(`Fitness difference: ${(fitnessShooting - fitnessNothing).toFixed(1)}`);
|
||||||
|
|
||||||
|
console.log('\n=== TEST COMPLETE ===');
|
||||||
60
src/apps/AsteroidsAI/destruction.test.ts
Normal file
60
src/apps/AsteroidsAI/destruction.test.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
// Test asteroid destruction mechanics
|
||||||
|
import { AsteroidsSimulation } from './AsteroidsSimulation';
|
||||||
|
|
||||||
|
console.log('=== ASTEROID DESTRUCTION TEST ===\n');
|
||||||
|
|
||||||
|
// Test: How many hits does it take to destroy asteroids?
|
||||||
|
const sim = new AsteroidsSimulation(42);
|
||||||
|
|
||||||
|
console.log('Initial asteroids:', sim.asteroids.length);
|
||||||
|
console.log('Initial asteroid sizes:', sim.asteroids.map(a => a.size));
|
||||||
|
|
||||||
|
// Fire 100 shots in a circle to hit asteroids
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
// Rotate and shoot constantly
|
||||||
|
const rotation = Math.sin(i * 0.1);
|
||||||
|
sim.update([rotation, 1, 1]); // Rotate, thrust, shoot
|
||||||
|
|
||||||
|
if (i % 20 === 0) {
|
||||||
|
console.log(`\nStep ${i}:`);
|
||||||
|
console.log(` Asteroids: ${sim.asteroids.length}`);
|
||||||
|
console.log(` Destroyed: ${sim.asteroidsDestroyed}`);
|
||||||
|
console.log(` Shots fired: ${sim.shotsFired}`);
|
||||||
|
console.log(` Shots hit: ${sim.shotsHit}`);
|
||||||
|
console.log(` Bullets active: ${sim.bullets.length}`);
|
||||||
|
console.log(` Hit rate: ${sim.shotsFired > 0 ? ((sim.shotsHit / sim.shotsFired) * 100).toFixed(1) : 0}%`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n=== FINAL RESULTS ===');
|
||||||
|
console.log(`Total asteroids destroyed: ${sim.asteroidsDestroyed}`);
|
||||||
|
console.log(`Total shots fired: ${sim.shotsFired}`);
|
||||||
|
console.log(`Total hits: ${sim.shotsHit}`);
|
||||||
|
console.log(`Hit rate: ${((sim.shotsHit / sim.shotsFired) * 100).toFixed(1)}%`);
|
||||||
|
console.log(`Asteroids per hit: ${(sim.asteroidsDestroyed / sim.shotsHit).toFixed(2)}`);
|
||||||
|
console.log(`\nExpected: 1 hit = 1 asteroid destroyed (they should split, not take multiple hits)`);
|
||||||
|
|
||||||
|
// Test 2: Single asteroid, single bullet
|
||||||
|
console.log('\n=== SINGLE HIT TEST ===');
|
||||||
|
const sim2 = new AsteroidsSimulation(100);
|
||||||
|
const initialCount = sim2.asteroids.length;
|
||||||
|
const initialDestroyed = sim2.asteroidsDestroyed;
|
||||||
|
|
||||||
|
// Position ship to face an asteroid and shoot
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
sim2.update([0, 0, i === 20 ? 1 : 0]); // Shoot once at step 20
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Initial asteroids: ${initialCount}`);
|
||||||
|
console.log(`Final asteroids: ${sim2.asteroids.length}`);
|
||||||
|
console.log(`Destroyed: ${sim2.asteroidsDestroyed - initialDestroyed}`);
|
||||||
|
console.log(`Shots fired: ${sim2.shotsFired}`);
|
||||||
|
console.log(`Hits: ${sim2.shotsHit}`);
|
||||||
|
|
||||||
|
if (sim2.shotsHit > 0 && sim2.asteroidsDestroyed > initialDestroyed) {
|
||||||
|
console.log('✓ One hit destroys one asteroid (correct!)');
|
||||||
|
} else if (sim2.shotsHit === 0) {
|
||||||
|
console.log('⚠ No hits registered (might need better aim)');
|
||||||
|
} else {
|
||||||
|
console.log('✗ Hit registered but no destruction (BUG!)');
|
||||||
|
}
|
||||||
43
src/apps/AsteroidsAI/fitnessConfig.ts
Normal file
43
src/apps/AsteroidsAI/fitnessConfig.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { AsteroidsSimulation } from './AsteroidsSimulation';
|
||||||
|
|
||||||
|
export function calculateFitness(sim: AsteroidsSimulation): number {
|
||||||
|
// REDESIGNED: Make MOVEMENT absolutely essential
|
||||||
|
|
||||||
|
// 1. Asteroids destroyed (MASSIVE REWARD - this is the main goal)
|
||||||
|
const destructionScore = sim.asteroidsDestroyed * 2000;
|
||||||
|
|
||||||
|
// 2. Score from game (size-based rewards)
|
||||||
|
const gameScore = sim.score * 10;
|
||||||
|
|
||||||
|
// 3. Survival time (MINIMAL - just a tiny base)
|
||||||
|
const cappedTime = Math.min(sim.timeSteps, 1000);
|
||||||
|
const survivalScore = cappedTime * 0.5; // Reduced from 1
|
||||||
|
|
||||||
|
// 4. Shooting engagement reward
|
||||||
|
const shootingReward = Math.min(sim.shotsFired, 150) * 5;
|
||||||
|
|
||||||
|
// 5. Accuracy multiplier
|
||||||
|
const accuracy = sim.shotsFired > 0 ? sim.shotsHit / sim.shotsFired : 0;
|
||||||
|
const accuracyBonus = accuracy * sim.shotsFired * 20;
|
||||||
|
|
||||||
|
// 6. Destruction efficiency
|
||||||
|
const destructionRate = sim.timeSteps > 0 ? sim.asteroidsDestroyed / (sim.timeSteps / 60) : 0;
|
||||||
|
const efficiencyBonus = destructionRate * 1000;
|
||||||
|
|
||||||
|
// 7. MOVEMENT REWARD: ABSOLUTELY ESSENTIAL
|
||||||
|
// Exponential scaling - more distance = exponentially better
|
||||||
|
const distanceReward = sim.totalDistanceTraveled * 50; // 50 points per pixel!
|
||||||
|
const distanceBonus = sim.totalDistanceTraveled > 1000 ?
|
||||||
|
(sim.totalDistanceTraveled - 1000) * 20 : 0; // Extra bonus for high movement
|
||||||
|
|
||||||
|
// 8. Speed bonus
|
||||||
|
const avgSpeed = Math.sqrt(sim.ship.velocity.x ** 2 + sim.ship.velocity.y ** 2);
|
||||||
|
const speedBonus = avgSpeed * 300;
|
||||||
|
|
||||||
|
// Total fitness
|
||||||
|
const fitness = destructionScore + gameScore + survivalScore + shootingReward +
|
||||||
|
accuracyBonus + efficiencyBonus + distanceReward + distanceBonus + speedBonus;
|
||||||
|
|
||||||
|
return Math.max(0, fitness);
|
||||||
|
}
|
||||||
|
|
||||||
119
src/apps/AsteroidsAI/training.worker.ts
Normal file
119
src/apps/AsteroidsAI/training.worker.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { AsteroidsSimulation } from './AsteroidsSimulation';
|
||||||
|
import { calculateFitness } from './fitnessConfig';
|
||||||
|
import { GeneticAlgo } from './GeneticAlgo';
|
||||||
|
import { DenseNetwork } from './DenseNetwork';
|
||||||
|
import { CONFIG, getLayerSizes } from './config';
|
||||||
|
|
||||||
|
// Get architecture from config
|
||||||
|
const LAYER_SIZES = getLayerSizes();
|
||||||
|
const POPULATION_SIZE = CONFIG.POPULATION_SIZE;
|
||||||
|
const SCENARIOS = CONFIG.SCENARIOS_PER_GENOME;
|
||||||
|
|
||||||
|
let ga: GeneticAlgo | null = null;
|
||||||
|
let isRunning = false;
|
||||||
|
|
||||||
|
self.onmessage = (e: MessageEvent) => {
|
||||||
|
const { type } = e.data;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'start':
|
||||||
|
case 'reset':
|
||||||
|
console.log('Worker: Initializing Asteroids AI GA');
|
||||||
|
console.log('Architecture:', LAYER_SIZES);
|
||||||
|
console.log('Population:', POPULATION_SIZE);
|
||||||
|
console.log('Mutation Rate:', CONFIG.MUTATION_RATE);
|
||||||
|
console.log('Mutation Scale:', CONFIG.MUTATION_SCALE);
|
||||||
|
ga = new GeneticAlgo(POPULATION_SIZE, LAYER_SIZES, CONFIG.MUTATION_RATE, CONFIG.MUTATION_SCALE);
|
||||||
|
isRunning = true;
|
||||||
|
runGeneration();
|
||||||
|
break;
|
||||||
|
case 'pause':
|
||||||
|
isRunning = false;
|
||||||
|
break;
|
||||||
|
case 'resume':
|
||||||
|
if (!isRunning) {
|
||||||
|
isRunning = true;
|
||||||
|
runGeneration();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function runGeneration() {
|
||||||
|
if (!ga || !isRunning) return;
|
||||||
|
|
||||||
|
const population = ga.getPopulation();
|
||||||
|
|
||||||
|
// 1. Evaluate Fitness
|
||||||
|
for (const genome of population) {
|
||||||
|
let totalFitness = 0;
|
||||||
|
const network = new DenseNetwork(LAYER_SIZES, genome.weights);
|
||||||
|
|
||||||
|
for (let i = 0; i < SCENARIOS; i++) {
|
||||||
|
// Seed logic: (Gen * Scenarios) + i
|
||||||
|
const seed = (ga.generation * SCENARIOS) + i;
|
||||||
|
const sim = new AsteroidsSimulation(seed);
|
||||||
|
|
||||||
|
// Simulation Loop
|
||||||
|
let step = 0;
|
||||||
|
while (!sim.isGameOver && step < 5000) {
|
||||||
|
const inputs = sim.getObservation();
|
||||||
|
const outputs = network.predict(inputs);
|
||||||
|
sim.update(outputs);
|
||||||
|
step++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fitness = calculateFitness(sim);
|
||||||
|
totalFitness += fitness;
|
||||||
|
|
||||||
|
// Debug logging for first genome of every 10th generation
|
||||||
|
if (genome === population[0] && ga.generation % 10 === 0 && i === 0) {
|
||||||
|
console.log(`Gen ${ga.generation} Sample:`, {
|
||||||
|
timeSteps: sim.timeSteps,
|
||||||
|
destroyed: sim.asteroidsDestroyed,
|
||||||
|
shotsFired: sim.shotsFired,
|
||||||
|
shotsHit: sim.shotsHit,
|
||||||
|
score: sim.score,
|
||||||
|
fitness: fitness.toFixed(1)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
genome.fitness = totalFitness / SCENARIOS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate stats before evolution
|
||||||
|
let sumFitness = 0;
|
||||||
|
let maxFitness = -Infinity;
|
||||||
|
let minFitness = Infinity;
|
||||||
|
for (const genome of population) {
|
||||||
|
sumFitness += genome.fitness;
|
||||||
|
if (genome.fitness > maxFitness) maxFitness = genome.fitness;
|
||||||
|
if (genome.fitness < minFitness) minFitness = genome.fitness;
|
||||||
|
}
|
||||||
|
const avgFitness = sumFitness / population.length;
|
||||||
|
|
||||||
|
// Log fitness range every 10 generations
|
||||||
|
if (ga.generation % 10 === 0) {
|
||||||
|
console.log(`Gen ${ga.generation} Fitness Range: ${minFitness.toFixed(1)} - ${maxFitness.toFixed(1)}, Avg: ${avgFitness.toFixed(1)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send update to main thread
|
||||||
|
const bestOfGen = population.find(g => g.fitness === maxFitness) || population[0];
|
||||||
|
|
||||||
|
self.postMessage({
|
||||||
|
type: 'generationParams',
|
||||||
|
payload: {
|
||||||
|
generation: ga.generation,
|
||||||
|
maxFitness: maxFitness,
|
||||||
|
avgFitness: avgFitness,
|
||||||
|
bestGenome: { weights: Array.from(bestOfGen.weights) }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Evolve to next generation
|
||||||
|
ga.evolve();
|
||||||
|
|
||||||
|
// Schedule next gen
|
||||||
|
setTimeout(runGeneration, 0);
|
||||||
|
}
|
||||||
82
src/apps/AsteroidsAI/useEvolutionWorker.ts
Normal file
82
src/apps/AsteroidsAI/useEvolutionWorker.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
|
||||||
|
export interface Stats {
|
||||||
|
generation: number;
|
||||||
|
maxFitness: number;
|
||||||
|
avgFitness: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoryItem {
|
||||||
|
generation: number;
|
||||||
|
best: number;
|
||||||
|
avg: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEvolutionWorker() {
|
||||||
|
const [isTraining, setIsTraining] = useState(false);
|
||||||
|
const [stats, setStats] = useState<Stats>({ generation: 0, maxFitness: 0, avgFitness: 0 });
|
||||||
|
const [fitnessHistory, setFitnessHistory] = useState<HistoryItem[]>([]);
|
||||||
|
const [bestGenome, setBestGenome] = useState<any>(null);
|
||||||
|
|
||||||
|
const workerRef = useRef<Worker | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const worker = new Worker(new URL('./training.worker.ts', import.meta.url), { type: 'module' });
|
||||||
|
workerRef.current = worker;
|
||||||
|
|
||||||
|
worker.onmessage = (e: MessageEvent<any>) => {
|
||||||
|
const { type, payload, error } = e.data;
|
||||||
|
|
||||||
|
if (type === 'generationParams') {
|
||||||
|
setStats({
|
||||||
|
generation: payload.generation,
|
||||||
|
maxFitness: payload.maxFitness,
|
||||||
|
avgFitness: payload.avgFitness
|
||||||
|
});
|
||||||
|
|
||||||
|
if (payload.bestGenome) {
|
||||||
|
setBestGenome(payload.bestGenome);
|
||||||
|
}
|
||||||
|
|
||||||
|
setFitnessHistory(prev => [...prev, {
|
||||||
|
generation: payload.generation,
|
||||||
|
best: payload.maxFitness,
|
||||||
|
avg: payload.avgFitness
|
||||||
|
}]);
|
||||||
|
} else if (type === 'error') {
|
||||||
|
console.error("Worker Error:", error);
|
||||||
|
setIsTraining(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial reset to setup GA
|
||||||
|
worker.postMessage({ type: 'reset' });
|
||||||
|
|
||||||
|
return () => worker.terminate();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!workerRef.current) return;
|
||||||
|
workerRef.current.postMessage({ type: isTraining ? 'resume' : 'pause' });
|
||||||
|
}, [isTraining]);
|
||||||
|
|
||||||
|
const handleReset = useCallback(() => {
|
||||||
|
setStats({ generation: 0, maxFitness: 0, avgFitness: 0 });
|
||||||
|
setFitnessHistory([]);
|
||||||
|
setBestGenome(null);
|
||||||
|
workerRef.current?.postMessage({ type: 'reset' });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleTraining = useCallback(() => {
|
||||||
|
setIsTraining(prev => !prev);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isTraining,
|
||||||
|
stats,
|
||||||
|
fitnessHistory,
|
||||||
|
bestGenome,
|
||||||
|
toggleTraining,
|
||||||
|
handleReset
|
||||||
|
};
|
||||||
|
}
|
||||||
2
src/apps/BridgeBuilder/.reload
Normal file
2
src/apps/BridgeBuilder/.reload
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// Force reload marker - change me to trigger HMR
|
||||||
|
export const RELOAD_MARKER = 2;
|
||||||
240
src/apps/BridgeBuilder/BridgeBuilder.css
Normal file
240
src/apps/BridgeBuilder/BridgeBuilder.css
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
/* Bridge Builder App Styles */
|
||||||
|
|
||||||
|
.bridge-builder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bridge-builder__header {
|
||||||
|
padding: 1.5rem 2rem;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bridge-builder__title {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bridge-builder__subtitle {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #aaa;
|
||||||
|
margin: 0.25rem 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bridge-builder__content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bridge-builder__canvas-container {
|
||||||
|
flex: 1;
|
||||||
|
background: #0f0f1e;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bridge-builder__sidebar {
|
||||||
|
width: 320px;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bridge-builder__section {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bridge-builder__section-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: #fff;
|
||||||
|
border-bottom: 2px solid rgba(102, 126, 234, 0.5);
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bridge-builder__controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bridge-builder__button {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 80px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bridge-builder__button--primary {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bridge-builder__button--primary:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bridge-builder__button--secondary {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bridge-builder__button--secondary:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bridge-builder__button--danger {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
color: #ef4444;
|
||||||
|
border: 1px solid #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bridge-builder__button--danger:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bridge-builder__button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bridge-builder__stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bridge-builder__stat {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border-left: 3px solid #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bridge-builder__stat-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #aaa;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bridge-builder__stat-value {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bridge-builder__input-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bridge-builder__label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #ccc;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bridge-builder__input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: white;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bridge-builder__input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bridge-builder__select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: white;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bridge-builder__select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bridge-builder__slider {
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
outline: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bridge-builder__slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #667eea;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bridge-builder__slider::-webkit-slider-thumb:hover {
|
||||||
|
background: #764ba2;
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bridge-builder__slider::-moz-range-thumb {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #667eea;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bridge-builder__slider::-moz-range-thumb:hover {
|
||||||
|
background: #764ba2;
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bridge-builder__value-display {
|
||||||
|
display: inline-block;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #667eea;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
301
src/apps/BridgeBuilder/BridgeBuilderApp.tsx
Normal file
301
src/apps/BridgeBuilder/BridgeBuilderApp.tsx
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
// Bridge Builder Main App Component
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import Phaser from 'phaser';
|
||||||
|
import { BridgeScene } from './BridgeScene';
|
||||||
|
import { useEvolutionWorker } from './useEvolutionWorker';
|
||||||
|
import { FitnessGraph } from './FitnessGraph';
|
||||||
|
import { DEFAULT_BRIDGE_CONFIG, DEFAULT_SIM_CONFIG, DEFAULT_GA_CONFIG } from './types';
|
||||||
|
import type { BridgeConfig, SimulationConfig, GAConfig } from './types';
|
||||||
|
import './BridgeBuilder.css';
|
||||||
|
|
||||||
|
export default function BridgeBuilderApp() {
|
||||||
|
const canvasRef = useRef<HTMLDivElement>(null);
|
||||||
|
const gameRef = useRef<Phaser.Game | null>(null);
|
||||||
|
const sceneRef = useRef<BridgeScene | null>(null);
|
||||||
|
|
||||||
|
const [bridgeConfig, setBridgeConfig] = useState<BridgeConfig>(DEFAULT_BRIDGE_CONFIG);
|
||||||
|
const [simConfig, setSimConfig] = useState<SimulationConfig>(DEFAULT_SIM_CONFIG);
|
||||||
|
const [gaConfig, setGaConfig] = useState<GAConfig>(DEFAULT_GA_CONFIG);
|
||||||
|
|
||||||
|
const {
|
||||||
|
generation,
|
||||||
|
bestFitness,
|
||||||
|
avgFitness,
|
||||||
|
bestGenome,
|
||||||
|
isTraining,
|
||||||
|
bestFitnessHistory,
|
||||||
|
avgFitnessHistory,
|
||||||
|
startTraining,
|
||||||
|
stopTraining,
|
||||||
|
reset,
|
||||||
|
} = useEvolutionWorker(bridgeConfig, simConfig, gaConfig);
|
||||||
|
|
||||||
|
// Initialize Phaser
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canvasRef.current || gameRef.current) return;
|
||||||
|
|
||||||
|
const config: Phaser.Types.Core.GameConfig = {
|
||||||
|
type: Phaser.AUTO,
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
parent: canvasRef.current,
|
||||||
|
backgroundColor: '#0f0f1e',
|
||||||
|
scene: BridgeScene,
|
||||||
|
physics: {
|
||||||
|
default: 'matter',
|
||||||
|
matter: {
|
||||||
|
debug: false,
|
||||||
|
gravity: { x: 0, y: 9.81 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
gameRef.current = new Phaser.Game(config);
|
||||||
|
|
||||||
|
// Wait for scene to be ready
|
||||||
|
setTimeout(() => {
|
||||||
|
if (gameRef.current) {
|
||||||
|
sceneRef.current = gameRef.current.scene.getScene('BridgeScene') as BridgeScene;
|
||||||
|
console.log('[App] Scene ref captured:', !!sceneRef.current);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
gameRef.current?.destroy(true);
|
||||||
|
gameRef.current = null;
|
||||||
|
sceneRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Update scene when best genome changes
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('[App] useEffect triggered - updating scene', {
|
||||||
|
hasScene: !!sceneRef.current,
|
||||||
|
hasGenome: !!bestGenome,
|
||||||
|
generation,
|
||||||
|
nodes: bestGenome?.nodes.length,
|
||||||
|
beams: bestGenome?.beams.length
|
||||||
|
});
|
||||||
|
if (sceneRef.current && bestGenome) {
|
||||||
|
sceneRef.current.updateBridge(bestGenome);
|
||||||
|
sceneRef.current.updateStats(generation, bestFitness);
|
||||||
|
}
|
||||||
|
}, [bestGenome, generation, bestFitness]);
|
||||||
|
|
||||||
|
const handleStartStop = () => {
|
||||||
|
if (isTraining) {
|
||||||
|
stopTraining();
|
||||||
|
} else {
|
||||||
|
startTraining();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
reset();
|
||||||
|
if (sceneRef.current) {
|
||||||
|
sceneRef.current.updateStats(0, 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bridge-builder">
|
||||||
|
<header className="bridge-builder__header">
|
||||||
|
<h1 className="bridge-builder__title">🌉 Bridge Builder</h1>
|
||||||
|
<p className="bridge-builder__subtitle">
|
||||||
|
Watch evolution discover structural engineering solutions
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="bridge-builder__content">
|
||||||
|
<div className="bridge-builder__canvas-container" ref={canvasRef} />
|
||||||
|
|
||||||
|
<aside className="bridge-builder__sidebar">
|
||||||
|
<section className="bridge-builder__section">
|
||||||
|
<h2 className="bridge-builder__section-title">Controls</h2>
|
||||||
|
<div className="bridge-builder__controls">
|
||||||
|
<button
|
||||||
|
className={`bridge-builder__button ${isTraining ? 'bridge-builder__button--secondary' : 'bridge-builder__button--primary'
|
||||||
|
}`}
|
||||||
|
onClick={handleStartStop}
|
||||||
|
>
|
||||||
|
{isTraining ? 'Pause' : 'Start'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="bridge-builder__button bridge-builder__button--danger"
|
||||||
|
onClick={handleReset}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="bridge-builder__section">
|
||||||
|
<h2 className="bridge-builder__section-title">Bridge Config</h2>
|
||||||
|
|
||||||
|
<div className="bridge-builder__input-group">
|
||||||
|
<label className="bridge-builder__label">
|
||||||
|
Load Mass
|
||||||
|
<span className="bridge-builder__value-display">{bridgeConfig.loadMass}kg</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className="bridge-builder__slider"
|
||||||
|
min="0.1"
|
||||||
|
max="50"
|
||||||
|
step="0.5"
|
||||||
|
value={bridgeConfig.loadMass}
|
||||||
|
onChange={(e) => setBridgeConfig({ ...bridgeConfig, loadMass: Number(e.target.value) })}
|
||||||
|
disabled={isTraining}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bridge-builder__input-group">
|
||||||
|
<label className="bridge-builder__label">
|
||||||
|
Beam Strength
|
||||||
|
<span className="bridge-builder__value-display">{bridgeConfig.beamStrength}N</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className="bridge-builder__slider"
|
||||||
|
min="500"
|
||||||
|
max="10000"
|
||||||
|
step="100"
|
||||||
|
value={bridgeConfig.beamStrength}
|
||||||
|
onChange={(e) => setBridgeConfig({ ...bridgeConfig, beamStrength: Number(e.target.value) })}
|
||||||
|
disabled={isTraining}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="bridge-builder__section">
|
||||||
|
<h2 className="bridge-builder__section-title">Statistics</h2>
|
||||||
|
<div className="bridge-builder__stats">
|
||||||
|
<div className="bridge-builder__stat">
|
||||||
|
<div className="bridge-builder__stat-label">Generation</div>
|
||||||
|
<div className="bridge-builder__stat-value">{generation}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bridge-builder__stat">
|
||||||
|
<div className="bridge-builder__stat-label">Best Fitness</div>
|
||||||
|
<div className="bridge-builder__stat-value">{bestFitness.toFixed(0)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bridge-builder__stat">
|
||||||
|
<div className="bridge-builder__stat-label">Avg Fitness</div>
|
||||||
|
<div className="bridge-builder__stat-value">{avgFitness.toFixed(0)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bridge-builder__stat">
|
||||||
|
<div className="bridge-builder__stat-label">Nodes</div>
|
||||||
|
<div className="bridge-builder__stat-value">{bestGenome?.nodes.length || 0}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="bridge-builder__section">
|
||||||
|
<h2 className="bridge-builder__section-title">Fitness Progress</h2>
|
||||||
|
<FitnessGraph
|
||||||
|
bestFitnessHistory={bestFitnessHistory}
|
||||||
|
avgFitnessHistory={avgFitnessHistory}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="bridge-builder__section">
|
||||||
|
<h2 className="bridge-builder__section-title">Simulation</h2>
|
||||||
|
|
||||||
|
<div className="bridge-builder__input-group">
|
||||||
|
<label className="bridge-builder__label">
|
||||||
|
Population Size
|
||||||
|
<span className="bridge-builder__value-display">{simConfig.populationSize}</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className="bridge-builder__slider"
|
||||||
|
min="10"
|
||||||
|
max="100"
|
||||||
|
step="10"
|
||||||
|
value={simConfig.populationSize}
|
||||||
|
onChange={(e) => setSimConfig({ ...simConfig, populationSize: Number(e.target.value) })}
|
||||||
|
disabled={isTraining}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bridge-builder__input-group">
|
||||||
|
<label className="bridge-builder__label">
|
||||||
|
Max Steps
|
||||||
|
<span className="bridge-builder__value-display">{simConfig.maxSteps}</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className="bridge-builder__slider"
|
||||||
|
min="300"
|
||||||
|
max="1200"
|
||||||
|
step="100"
|
||||||
|
value={simConfig.maxSteps}
|
||||||
|
onChange={(e) => setSimConfig({ ...simConfig, maxSteps: Number(e.target.value) })}
|
||||||
|
disabled={isTraining}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="bridge-builder__section">
|
||||||
|
<h2 className="bridge-builder__section-title">Evolution</h2>
|
||||||
|
|
||||||
|
<div className="bridge-builder__input-group">
|
||||||
|
<label className="bridge-builder__label">
|
||||||
|
Mutation Rate
|
||||||
|
<span className="bridge-builder__value-display">{(gaConfig.mutationRate * 100).toFixed(0)}%</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className="bridge-builder__slider"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.1"
|
||||||
|
value={gaConfig.mutationRate}
|
||||||
|
onChange={(e) => setGaConfig({ ...gaConfig, mutationRate: Number(e.target.value) })}
|
||||||
|
disabled={isTraining}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bridge-builder__input-group">
|
||||||
|
<label className="bridge-builder__label">
|
||||||
|
Elite Count
|
||||||
|
<span className="bridge-builder__value-display">{gaConfig.eliteCount}</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className="bridge-builder__slider"
|
||||||
|
min="1"
|
||||||
|
max="10"
|
||||||
|
step="1"
|
||||||
|
value={gaConfig.eliteCount}
|
||||||
|
onChange={(e) => setGaConfig({ ...gaConfig, eliteCount: Number(e.target.value) })}
|
||||||
|
disabled={isTraining}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="bridge-builder__section">
|
||||||
|
<h2 className="bridge-builder__section-title">Legend</h2>
|
||||||
|
<div style={{ fontSize: '0.85rem', lineHeight: '1.6' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.5rem' }}>
|
||||||
|
<div style={{ width: 40, height: 3, background: '#4ade80' }}></div>
|
||||||
|
<span>Low Stress</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.5rem' }}>
|
||||||
|
<div style={{ width: 40, height: 3, background: '#60a5fa' }}></div>
|
||||||
|
<span>Tension</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.5rem' }}>
|
||||||
|
<div style={{ width: 40, height: 3, background: '#f59e0b' }}></div>
|
||||||
|
<span>Compression</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
|
<div style={{ width: 40, height: 3, background: '#ef4444' }}></div>
|
||||||
|
<span>High Stress</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
206
src/apps/BridgeBuilder/BridgeScene.ts
Normal file
206
src/apps/BridgeBuilder/BridgeScene.ts
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
// Phaser Scene for Bridge Visualization
|
||||||
|
import Phaser from 'phaser';
|
||||||
|
import { BridgeSimulation } from './BridgeSimulation';
|
||||||
|
import type { BridgeGenome, BridgeConfig, SimulationConfig } from './types';
|
||||||
|
import { DEFAULT_BRIDGE_CONFIG, DEFAULT_SIM_CONFIG } from './types';
|
||||||
|
|
||||||
|
export class BridgeScene extends Phaser.Scene {
|
||||||
|
private sim!: BridgeSimulation;
|
||||||
|
private graphics!: Phaser.GameObjects.Graphics;
|
||||||
|
private statsText!: Phaser.GameObjects.Text;
|
||||||
|
|
||||||
|
private currentGenome: BridgeGenome | null = null;
|
||||||
|
private generation = 0;
|
||||||
|
private bestFitness = 0;
|
||||||
|
|
||||||
|
private bridgeConfig: BridgeConfig = DEFAULT_BRIDGE_CONFIG;
|
||||||
|
private simConfig: SimulationConfig = DEFAULT_SIM_CONFIG;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super({ key: 'BridgeScene' });
|
||||||
|
}
|
||||||
|
|
||||||
|
create() {
|
||||||
|
// Setup graphics
|
||||||
|
this.graphics = this.add.graphics();
|
||||||
|
|
||||||
|
// Stats text
|
||||||
|
this.statsText = this.add.text(10, 10, '', {
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#ffffff',
|
||||||
|
backgroundColor: '#00000088',
|
||||||
|
padding: { x: 10, y: 5 },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create initial simple bridge for demonstration
|
||||||
|
this.createDemoBridge();
|
||||||
|
}
|
||||||
|
|
||||||
|
private createDemoBridge() {
|
||||||
|
// Simple triangle bridge
|
||||||
|
const genome: BridgeGenome = {
|
||||||
|
nodes: [
|
||||||
|
{ x: 0.5, y: 0.5 }, // Center top
|
||||||
|
],
|
||||||
|
beams: [
|
||||||
|
{ nodeA: -1, nodeB: 0 }, // Left anchor to center
|
||||||
|
{ nodeA: -2, nodeB: 0 }, // Right anchor to center
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
this.updateBridge(genome);
|
||||||
|
}
|
||||||
|
|
||||||
|
public updateBridge(genome: BridgeGenome) {
|
||||||
|
console.log('[BridgeScene] updateBridge called:', { nodes: genome.nodes.length, beams: genome.beams.length });
|
||||||
|
this.currentGenome = genome;
|
||||||
|
|
||||||
|
// Create new simulation
|
||||||
|
if (this.sim) {
|
||||||
|
// Cleanup old sim if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sim = new BridgeSimulation(genome, this.bridgeConfig, this.simConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
public updateStats(generation: number, fitness: number) {
|
||||||
|
console.log('[BridgeScene] updateStats called:', { generation, fitness });
|
||||||
|
this.generation = generation;
|
||||||
|
this.bestFitness = fitness;
|
||||||
|
}
|
||||||
|
|
||||||
|
update() {
|
||||||
|
if (!this.sim) return;
|
||||||
|
|
||||||
|
// Update simulation
|
||||||
|
if (!this.sim.isFinished()) {
|
||||||
|
this.sim.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
private render() {
|
||||||
|
this.graphics.clear();
|
||||||
|
|
||||||
|
// Draw ground
|
||||||
|
this.drawGround();
|
||||||
|
|
||||||
|
// Draw bridge structure
|
||||||
|
this.drawBridge();
|
||||||
|
|
||||||
|
// Update stats
|
||||||
|
this.updateStatsText();
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawGround() {
|
||||||
|
const groundY = this.bridgeConfig.anchorHeight + 350;
|
||||||
|
this.graphics.lineStyle(2, 0x444444);
|
||||||
|
this.graphics.beginPath();
|
||||||
|
this.graphics.moveTo(0, groundY);
|
||||||
|
this.graphics.lineTo(800, groundY);
|
||||||
|
this.graphics.strokePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawBridge() {
|
||||||
|
if (!this.sim || !this.currentGenome) return;
|
||||||
|
|
||||||
|
const { anchorHeight, spanWidth, nodeRadius } = this.bridgeConfig;
|
||||||
|
|
||||||
|
// Draw nodes and beams
|
||||||
|
const beamForces = this.sim.getBeamForces();
|
||||||
|
const nodePositions = this.sim.getNodePositions();
|
||||||
|
const loadPos = this.sim.getLoadPosition();
|
||||||
|
|
||||||
|
// Draw beams
|
||||||
|
for (let i = 0; i < this.currentGenome.beams.length; i++) {
|
||||||
|
const beam = this.currentGenome.beams[i];
|
||||||
|
const force = beamForces[i];
|
||||||
|
|
||||||
|
// Get node positions
|
||||||
|
let posA: { x: number; y: number };
|
||||||
|
let posB: { x: number; y: number };
|
||||||
|
|
||||||
|
if (beam.nodeA === -1) {
|
||||||
|
posA = { x: 100, y: anchorHeight };
|
||||||
|
} else if (beam.nodeA === -2) {
|
||||||
|
posA = { x: 100 + spanWidth, y: anchorHeight };
|
||||||
|
} else {
|
||||||
|
posA = nodePositions[beam.nodeA];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (beam.nodeB === -1) {
|
||||||
|
posB = { x: 100, y: anchorHeight };
|
||||||
|
} else if (beam.nodeB === -2) {
|
||||||
|
posB = { x: 100 + spanWidth, y: anchorHeight };
|
||||||
|
} else {
|
||||||
|
posB = nodePositions[beam.nodeB];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!posA || !posB) continue;
|
||||||
|
|
||||||
|
// Color by stress
|
||||||
|
const color = force?.broken
|
||||||
|
? 0x666666
|
||||||
|
: this.getStressColor(force?.force || 0);
|
||||||
|
|
||||||
|
const lineWidth = force?.broken ? 1 : 3;
|
||||||
|
|
||||||
|
this.graphics.lineStyle(lineWidth, color);
|
||||||
|
this.graphics.beginPath();
|
||||||
|
this.graphics.moveTo(posA.x, posA.y);
|
||||||
|
this.graphics.lineTo(posB.x, posB.y);
|
||||||
|
this.graphics.strokePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw nodes
|
||||||
|
for (const pos of nodePositions) {
|
||||||
|
this.graphics.fillStyle(0xcccccc);
|
||||||
|
this.graphics.fillCircle(pos.x, pos.y, nodeRadius);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw anchors
|
||||||
|
this.graphics.fillStyle(0x888888);
|
||||||
|
this.graphics.fillCircle(100, anchorHeight, nodeRadius * 2);
|
||||||
|
this.graphics.fillCircle(100 + spanWidth, anchorHeight, nodeRadius * 2);
|
||||||
|
|
||||||
|
// Draw load
|
||||||
|
this.graphics.fillStyle(0xff6b6b);
|
||||||
|
this.graphics.fillRect(loadPos.x - 15, loadPos.y - 15, 30, 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getStressColor(force: number): number {
|
||||||
|
// Color mapping: Blue (tension) -> Green (low) -> Red (compression)
|
||||||
|
const maxForce = this.bridgeConfig.beamStrength;
|
||||||
|
const ratio = Math.abs(force) / maxForce;
|
||||||
|
|
||||||
|
if (ratio > 1) return 0xff0000; // Red - overstressed
|
||||||
|
|
||||||
|
// Low stress = green
|
||||||
|
if (ratio < 0.3) return 0x4ade80;
|
||||||
|
|
||||||
|
// Tension (positive) = blue shades
|
||||||
|
if (force > 0) {
|
||||||
|
const hue = 200 - ratio * 40; // Blue to cyan
|
||||||
|
return Phaser.Display.Color.HSLToColor(hue / 360, 0.7, 0.5).color;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compression (negative) = yellow to red
|
||||||
|
const hue = 50 - ratio * 50; // Yellow to red
|
||||||
|
return Phaser.Display.Color.HSLToColor(hue / 360, 0.8, 0.5).color;
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateStatsText() {
|
||||||
|
const result = this.sim?.getResult();
|
||||||
|
|
||||||
|
this.statsText.setText([
|
||||||
|
`Generation: ${this.generation}`,
|
||||||
|
`Best Fitness: ${this.bestFitness.toFixed(0)}`,
|
||||||
|
`Nodes: ${this.currentGenome?.nodes.length || 0}`,
|
||||||
|
`Beams: ${this.currentGenome?.beams.length || 0}`,
|
||||||
|
`Steps: ${result?.stepsSupported || 0}`,
|
||||||
|
`Status: ${result?.collapsed ? 'Collapsed' : 'Standing'}`,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
434
src/apps/BridgeBuilder/BridgeSimulation.ts
Normal file
434
src/apps/BridgeBuilder/BridgeSimulation.ts
Normal file
@@ -0,0 +1,434 @@
|
|||||||
|
// Bridge Physics Simulation using Matter.js
|
||||||
|
// @ts-ignore
|
||||||
|
import decomp from 'poly-decomp';
|
||||||
|
import Matter from 'matter-js';
|
||||||
|
import type { BridgeGenome, BridgeConfig, SimulationConfig, BeamForce, SimulationResult } from './types';
|
||||||
|
import { DEFAULT_BRIDGE_CONFIG, DEFAULT_SIM_CONFIG } from './types';
|
||||||
|
|
||||||
|
Matter.Common.setDecomp(decomp);
|
||||||
|
|
||||||
|
export class BridgeSimulation {
|
||||||
|
public engine: Matter.Engine;
|
||||||
|
|
||||||
|
private nodes: Matter.Body[] = [];
|
||||||
|
private beams: Matter.Constraint[] = [];
|
||||||
|
private loadSupports: { constraint: Matter.Constraint; nodeIdx: number }[] = [];
|
||||||
|
private load: Matter.Body;
|
||||||
|
private anchorLeft: Matter.Body;
|
||||||
|
private anchorRight: Matter.Body;
|
||||||
|
|
||||||
|
private genome: BridgeGenome;
|
||||||
|
private bridgeConfig: BridgeConfig;
|
||||||
|
private simConfig: SimulationConfig;
|
||||||
|
|
||||||
|
private currentStep = 0;
|
||||||
|
private loadHeightSum = 0;
|
||||||
|
private collapsed = false;
|
||||||
|
private brokenBeams = new Set<number>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
genome: BridgeGenome,
|
||||||
|
bridgeConfig: BridgeConfig = DEFAULT_BRIDGE_CONFIG,
|
||||||
|
simConfig: SimulationConfig = DEFAULT_SIM_CONFIG
|
||||||
|
) {
|
||||||
|
this.genome = genome;
|
||||||
|
this.bridgeConfig = bridgeConfig;
|
||||||
|
this.simConfig = simConfig;
|
||||||
|
|
||||||
|
// Create physics engine
|
||||||
|
this.engine = Matter.Engine.create();
|
||||||
|
this.engine.gravity.y = 9.81; // m/s^2
|
||||||
|
|
||||||
|
// Create anchor points (static)
|
||||||
|
const anchorY = this.bridgeConfig.anchorHeight;
|
||||||
|
this.anchorLeft = Matter.Bodies.circle(100, anchorY, this.bridgeConfig.nodeRadius * 2, {
|
||||||
|
isStatic: true,
|
||||||
|
label: 'anchor',
|
||||||
|
render: { fillStyle: '#888' }
|
||||||
|
});
|
||||||
|
|
||||||
|
this.anchorRight = Matter.Bodies.circle(
|
||||||
|
100 + this.bridgeConfig.spanWidth,
|
||||||
|
anchorY,
|
||||||
|
this.bridgeConfig.nodeRadius * 2,
|
||||||
|
{
|
||||||
|
isStatic: true,
|
||||||
|
label: 'anchor',
|
||||||
|
render: { fillStyle: '#888' }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Matter.World.add(this.engine.world, [this.anchorLeft, this.anchorRight]);
|
||||||
|
|
||||||
|
// Create nodes from genome
|
||||||
|
this.createNodes();
|
||||||
|
|
||||||
|
// Create beams from genome
|
||||||
|
this.createBeams();
|
||||||
|
|
||||||
|
// Create load (suspended from center of bridge)
|
||||||
|
this.load = this.createLoad();
|
||||||
|
Matter.World.add(this.engine.world, this.load);
|
||||||
|
|
||||||
|
// Debug: Log construction details
|
||||||
|
if (this.nodes.length === 0) {
|
||||||
|
console.warn('[BridgeSim] WARNING: No nodes created!');
|
||||||
|
}
|
||||||
|
if (this.beams.length === 0) {
|
||||||
|
console.warn('[BridgeSim] WARNING: No beams created!');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createNodes() {
|
||||||
|
const { nodes } = this.genome;
|
||||||
|
const { spanWidth, anchorHeight, nodeRadius } = this.bridgeConfig;
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
// Convert relative coords (0-1) to world coords
|
||||||
|
const x = 100 + node.x * spanWidth;
|
||||||
|
const y = anchorHeight + node.y * 150;
|
||||||
|
|
||||||
|
// Safety: Skip invalid nodes
|
||||||
|
if (isNaN(x) || isNaN(y)) {
|
||||||
|
console.warn('[BridgeSim] Skipping node with NaN coordinates:', node);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = Matter.Bodies.circle(x, y, nodeRadius, {
|
||||||
|
label: 'node',
|
||||||
|
density: 0.001, // Light nodes
|
||||||
|
frictionAir: 0.01,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.nodes.push(body);
|
||||||
|
Matter.World.add(this.engine.world, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createBeams() {
|
||||||
|
const { beams } = this.genome;
|
||||||
|
const { beamStiffness } = this.bridgeConfig;
|
||||||
|
|
||||||
|
// Add beams connecting to left anchor
|
||||||
|
const leftConnections = beams.filter(b => b.nodeA === -1 || b.nodeB === -1);
|
||||||
|
for (const beam of leftConnections) {
|
||||||
|
const nodeIdx = beam.nodeA === -1 ? beam.nodeB : beam.nodeA;
|
||||||
|
if (nodeIdx >= 0 && nodeIdx < this.nodes.length) {
|
||||||
|
const constraint = Matter.Constraint.create({
|
||||||
|
bodyA: this.anchorLeft,
|
||||||
|
bodyB: this.nodes[nodeIdx],
|
||||||
|
stiffness: beamStiffness,
|
||||||
|
damping: 0.01,
|
||||||
|
label: 'beam',
|
||||||
|
});
|
||||||
|
this.beams.push(constraint);
|
||||||
|
Matter.World.add(this.engine.world, constraint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add beams connecting to right anchor
|
||||||
|
const rightConnections = beams.filter(b => b.nodeA === -2 || b.nodeB === -2);
|
||||||
|
for (const beam of rightConnections) {
|
||||||
|
const nodeIdx = beam.nodeA === -2 ? beam.nodeB : beam.nodeA;
|
||||||
|
if (nodeIdx >= 0 && nodeIdx < this.nodes.length) {
|
||||||
|
const constraint = Matter.Constraint.create({
|
||||||
|
bodyA: this.anchorRight,
|
||||||
|
bodyB: this.nodes[nodeIdx],
|
||||||
|
stiffness: beamStiffness,
|
||||||
|
damping: 0.01,
|
||||||
|
label: 'beam',
|
||||||
|
});
|
||||||
|
this.beams.push(constraint);
|
||||||
|
Matter.World.add(this.engine.world, constraint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add beams between nodes
|
||||||
|
const nodeBeams = beams.filter(b => b.nodeA >= 0 && b.nodeB >= 0);
|
||||||
|
for (const beam of nodeBeams) {
|
||||||
|
if (beam.nodeA < this.nodes.length && beam.nodeB < this.nodes.length) {
|
||||||
|
const constraint = Matter.Constraint.create({
|
||||||
|
bodyA: this.nodes[beam.nodeA],
|
||||||
|
bodyB: this.nodes[beam.nodeB],
|
||||||
|
stiffness: beamStiffness,
|
||||||
|
damping: 0.01,
|
||||||
|
label: 'beam',
|
||||||
|
});
|
||||||
|
this.beams.push(constraint);
|
||||||
|
Matter.World.add(this.engine.world, constraint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createLoad(): Matter.Body {
|
||||||
|
const centerX = 100 + this.bridgeConfig.spanWidth / 2;
|
||||||
|
const centerY = this.bridgeConfig.anchorHeight + 100;
|
||||||
|
const loadSize = 24;
|
||||||
|
|
||||||
|
const load = Matter.Bodies.rectangle(
|
||||||
|
centerX,
|
||||||
|
centerY,
|
||||||
|
loadSize,
|
||||||
|
loadSize,
|
||||||
|
{
|
||||||
|
label: 'load',
|
||||||
|
density: (this.bridgeConfig.loadMass / (loadSize * loadSize)) * 1.5,
|
||||||
|
friction: 0.1,
|
||||||
|
restitution: 0.3,
|
||||||
|
render: { fillStyle: '#ff6b6b' }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.load = load; // IMPORTANT: Assign before using in constraints!
|
||||||
|
|
||||||
|
// Find center nodes to attach load (if any exist)
|
||||||
|
if (this.nodes.length > 0) {
|
||||||
|
const attachNodes = this.nodes.filter(n => {
|
||||||
|
const dx = Math.abs(n.position.x - centerX);
|
||||||
|
return dx < this.bridgeConfig.spanWidth * 0.3; // Within 30% of center
|
||||||
|
});
|
||||||
|
|
||||||
|
// If no center nodes, attach to any nodes
|
||||||
|
const nodesToUse = attachNodes.length > 0 ? attachNodes : this.nodes;
|
||||||
|
|
||||||
|
// Sort by height (lowest y = highest up = closest to anchors)
|
||||||
|
nodesToUse.sort((a, b) => {
|
||||||
|
if (isNaN(a.position.y) || isNaN(b.position.y)) return 0;
|
||||||
|
return a.position.y - b.position.y;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attach to top few nodes
|
||||||
|
const attachCount = Math.min(3, nodesToUse.length);
|
||||||
|
this.loadSupports = nodesToUse.slice(0, attachCount).map(node => {
|
||||||
|
const idx = this.nodes.indexOf(node);
|
||||||
|
const constraint = Matter.Constraint.create({
|
||||||
|
bodyA: load, // Use local variable for safety
|
||||||
|
bodyB: node,
|
||||||
|
stiffness: 0.8,
|
||||||
|
length: Matter.Vector.magnitude(Matter.Vector.sub(node.position, load.position)),
|
||||||
|
render: { visible: true, strokeStyle: '#ff0000', lineWidth: 2 }
|
||||||
|
});
|
||||||
|
Matter.Composite.add(this.engine.world, constraint);
|
||||||
|
return { constraint, nodeIdx: idx };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[BridgeSim] Created load with ${this.loadSupports.length} support constraints`);
|
||||||
|
|
||||||
|
return load;
|
||||||
|
}
|
||||||
|
|
||||||
|
public update() {
|
||||||
|
if (this.collapsed) return;
|
||||||
|
|
||||||
|
// Step physics with MORE iterations for stability
|
||||||
|
Matter.Engine.update(this.engine, this.simConfig.timeStep, this.simConfig.physicsIterations);
|
||||||
|
this.currentStep++;
|
||||||
|
|
||||||
|
// Check beam forces and break if over threshold
|
||||||
|
this.checkBeamForces();
|
||||||
|
|
||||||
|
// Track load height for fitness (negative when below anchors = bad)
|
||||||
|
const loadY = this.load.position.y;
|
||||||
|
if (isNaN(loadY)) {
|
||||||
|
this.collapsed = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const loadHeight = this.bridgeConfig.anchorHeight - loadY;
|
||||||
|
this.loadHeightSum += loadHeight;
|
||||||
|
|
||||||
|
// Check if load hit ground (y > some threshold)
|
||||||
|
// Realism: If the load sags too much, it's a failure.
|
||||||
|
if (this.load.position.y > this.bridgeConfig.anchorHeight + 250) {
|
||||||
|
console.log(`[BridgeSim] Load collapsed at step ${this.currentStep}: y=${this.load.position.y.toFixed(1)}`);
|
||||||
|
this.collapsed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkBeamForces() {
|
||||||
|
// ONLY check structural beams, NOT load supports
|
||||||
|
for (let i = 0; i < this.beams.length; i++) {
|
||||||
|
if (this.brokenBeams.has(i)) continue;
|
||||||
|
|
||||||
|
const beam = this.beams[i];
|
||||||
|
const bodyA = beam.bodyA;
|
||||||
|
const bodyB = beam.bodyB;
|
||||||
|
|
||||||
|
if (!bodyA || !bodyB) continue;
|
||||||
|
|
||||||
|
const posA = bodyA.position;
|
||||||
|
const posB = bodyB.position;
|
||||||
|
|
||||||
|
const dx = posB.x - posA.x;
|
||||||
|
const dy = posB.y - posA.y;
|
||||||
|
const currentLength = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
const restLength = beam.length || currentLength;
|
||||||
|
|
||||||
|
const extension = currentLength - restLength;
|
||||||
|
const force = extension * (beam.stiffness || 1) * 1000; // Approximate
|
||||||
|
|
||||||
|
if (Math.abs(force) > this.bridgeConfig.beamStrength) {
|
||||||
|
// Debug first beam break
|
||||||
|
if (this.brokenBeams.size === 0) {
|
||||||
|
console.log(`[BridgeSim] First STRUCTURAL beam break at step ${this.currentStep}: force=${force.toFixed(0)}N (abs), threshold=${this.bridgeConfig.beamStrength}N`);
|
||||||
|
}
|
||||||
|
// Break beam
|
||||||
|
this.brokenBeams.add(i);
|
||||||
|
Matter.Composite.remove(this.engine.world, beam);
|
||||||
|
// REDUNDANCY: No longer setting this.collapsed = true here!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getConstraintForce(constraint: Matter.Constraint): number {
|
||||||
|
// Approximate force from constraint extension
|
||||||
|
const bodyA = constraint.bodyA;
|
||||||
|
const bodyB = constraint.bodyB;
|
||||||
|
|
||||||
|
if (!bodyA || !bodyB) return 0;
|
||||||
|
|
||||||
|
const posA = bodyA.position;
|
||||||
|
const posB = bodyB.position;
|
||||||
|
|
||||||
|
const dx = posB.x - posA.x;
|
||||||
|
const dy = posB.y - posA.y;
|
||||||
|
const currentLength = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
const restLength = constraint.length || currentLength;
|
||||||
|
|
||||||
|
const extension = currentLength - restLength;
|
||||||
|
const force = extension * (constraint.stiffness || 1) * 1000; // Approximate
|
||||||
|
|
||||||
|
return force;
|
||||||
|
}
|
||||||
|
|
||||||
|
public run(steps: number) {
|
||||||
|
for (let i = 0; i < steps; i++) {
|
||||||
|
this.update();
|
||||||
|
if (this.collapsed) break;
|
||||||
|
if (this.currentStep >= this.simConfig.maxSteps) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public isFinished(): boolean {
|
||||||
|
return this.collapsed || this.currentStep >= this.simConfig.maxSteps;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getBeamForces(): (BeamForce | null)[] {
|
||||||
|
const forces: (BeamForce | null)[] = [];
|
||||||
|
for (let i = 0; i < this.beams.length; i++) {
|
||||||
|
const beam = this.beams[i];
|
||||||
|
const isBroken = this.brokenBeams.has(i);
|
||||||
|
forces.push({
|
||||||
|
force: isBroken ? 0 : this.getConstraintForce(beam),
|
||||||
|
broken: isBroken,
|
||||||
|
nodeA: -1, // Not used by scene now
|
||||||
|
nodeB: -1, // Not used by scene now
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return forces;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getNodePositions(): { x: number; y: number }[] {
|
||||||
|
return this.nodes.map(node => ({ x: node.position.x, y: node.position.y }));
|
||||||
|
}
|
||||||
|
|
||||||
|
public getLoadPosition(): { x: number; y: number } {
|
||||||
|
return { x: this.load.position.x, y: this.load.position.y };
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasFullConnectivity(): boolean {
|
||||||
|
if (this.nodes.length === 0 || this.loadSupports.length === 0) return false;
|
||||||
|
|
||||||
|
// Check if there's a path from left anchor (-1) -> load (-3) -> right anchor (-2)
|
||||||
|
// using only currently active beams (not broken)
|
||||||
|
const activeBeams = this.genome.beams.filter((_, i) => !this.brokenBeams.has(i));
|
||||||
|
|
||||||
|
const adj = new Map<number, number[]>();
|
||||||
|
activeBeams.forEach(b => {
|
||||||
|
if (!adj.has(b.nodeA)) adj.set(b.nodeA, []);
|
||||||
|
if (!adj.has(b.nodeB)) adj.set(b.nodeB, []);
|
||||||
|
adj.get(b.nodeA)!.push(b.nodeB);
|
||||||
|
adj.get(b.nodeB)!.push(b.nodeA);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add load supports to adjacency (treat load as -3)
|
||||||
|
const loadNodeId = -3;
|
||||||
|
adj.set(loadNodeId, []);
|
||||||
|
this.loadSupports.forEach(support => {
|
||||||
|
const v = support.nodeIdx;
|
||||||
|
adj.get(loadNodeId)!.push(v);
|
||||||
|
if (!adj.has(v)) adj.set(v, []);
|
||||||
|
adj.get(v)!.push(loadNodeId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check path: -1 to -3
|
||||||
|
if (!adj.has(-1) || !adj.has(loadNodeId)) return false;
|
||||||
|
const hasLeftToLoad = this.bfs(adj, -1, loadNodeId);
|
||||||
|
if (!hasLeftToLoad) return false;
|
||||||
|
|
||||||
|
// Check path: -3 to -2
|
||||||
|
if (!adj.has(-2)) return false;
|
||||||
|
const hasLoadToRight = this.bfs(adj, loadNodeId, -2);
|
||||||
|
|
||||||
|
return hasLoadToRight;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bfs(adj: Map<number, number[]>, start: number, target: number): boolean {
|
||||||
|
const visited = new Set<number>();
|
||||||
|
const queue = [start];
|
||||||
|
visited.add(start);
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const u = queue.shift()!;
|
||||||
|
if (u === target) return true;
|
||||||
|
|
||||||
|
const neighbors = adj.get(u) || [];
|
||||||
|
for (const v of neighbors) {
|
||||||
|
if (!visited.has(v)) {
|
||||||
|
visited.add(v);
|
||||||
|
queue.push(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getResult(): SimulationResult {
|
||||||
|
const avgLoadHeight = this.loadHeightSum / Math.max(1, this.currentStep);
|
||||||
|
const beamCount = this.genome.beams.length;
|
||||||
|
const nodeCount = this.genome.nodes.length;
|
||||||
|
const connected = this.hasFullConnectivity();
|
||||||
|
|
||||||
|
// REDUCED survival rewards for disconnected bridges
|
||||||
|
const timeScale = connected ? 100 : 1;
|
||||||
|
const timeFitness = this.currentStep * timeScale;
|
||||||
|
|
||||||
|
// 2. Height Score (Only reward if connected)
|
||||||
|
const heightScore = connected ? avgLoadHeight * 5 : 0;
|
||||||
|
|
||||||
|
// 3. Material Penalty
|
||||||
|
const efficiencyFactor = Math.min(1, this.currentStep / 100);
|
||||||
|
const materialPenalty = ((beamCount * 5) + (nodeCount * 2)) * efficiencyFactor;
|
||||||
|
|
||||||
|
// 4. Structural Integrity Bonus
|
||||||
|
// MASSIVE reward for full connectivity side-to-side through load
|
||||||
|
const structureBonus = connected ? 5000 : 0;
|
||||||
|
|
||||||
|
// 5. Completion Bonus (Connected ONLY)
|
||||||
|
const completionBonus = (connected && this.currentStep >= this.simConfig.maxSteps && !this.collapsed) ? 10000 : 0;
|
||||||
|
|
||||||
|
// Minimum fitness 1 to keep it in the pool
|
||||||
|
const fitness = Math.max(1, timeFitness + heightScore + structureBonus + completionBonus - materialPenalty);
|
||||||
|
|
||||||
|
return {
|
||||||
|
fitness,
|
||||||
|
stepsSupported: this.currentStep,
|
||||||
|
avgLoadHeight,
|
||||||
|
beamCount,
|
||||||
|
maxStress: 0,
|
||||||
|
collapsed: this.collapsed || (!connected && this.currentStep > 10), // Treat disconnected as collapse
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
120
src/apps/BridgeBuilder/FitnessGraph.tsx
Normal file
120
src/apps/BridgeBuilder/FitnessGraph.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
// Fitness Graph Component for Bridge Builder
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
interface FitnessGraphProps {
|
||||||
|
bestFitnessHistory: number[];
|
||||||
|
avgFitnessHistory: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FitnessGraph({ bestFitnessHistory, avgFitnessHistory }: FitnessGraphProps) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
// Clear canvas
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
if (bestFitnessHistory.length < 2) return;
|
||||||
|
|
||||||
|
const width = canvas.width;
|
||||||
|
const height = canvas.height;
|
||||||
|
const padding = 40;
|
||||||
|
|
||||||
|
// Calculate bounds
|
||||||
|
const allValues = [...bestFitnessHistory, ...avgFitnessHistory];
|
||||||
|
const minFitness = Math.min(...allValues);
|
||||||
|
const maxFitness = Math.max(...allValues);
|
||||||
|
const fitnessRange = maxFitness - minFitness || 1;
|
||||||
|
|
||||||
|
const maxGen = bestFitnessHistory.length - 1;
|
||||||
|
|
||||||
|
// Draw grid
|
||||||
|
ctx.strokeStyle = '#2a2a3e';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
|
||||||
|
for (let i = 0; i <= 5; i++) {
|
||||||
|
const y = padding + (height - 2 * padding) * (i / 5);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(padding, y);
|
||||||
|
ctx.lineTo(width - padding, y);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Y-axis labels
|
||||||
|
const value = maxFitness - (fitnessRange * i / 5);
|
||||||
|
ctx.fillStyle = '#888';
|
||||||
|
ctx.font = '10px monospace';
|
||||||
|
ctx.textAlign = 'right';
|
||||||
|
ctx.fillText(value.toFixed(0), padding - 5, y + 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw best fitness line
|
||||||
|
ctx.strokeStyle = '#4ade80';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
|
||||||
|
bestFitnessHistory.forEach((fitness, i) => {
|
||||||
|
const x = padding + (width - 2 * padding) * (i / maxGen);
|
||||||
|
const y = padding + (height - 2 * padding) * (1 - (fitness - minFitness) / fitnessRange);
|
||||||
|
|
||||||
|
if (i === 0) {
|
||||||
|
ctx.moveTo(x, y);
|
||||||
|
} else {
|
||||||
|
ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Draw avg fitness line
|
||||||
|
ctx.strokeStyle = '#60a5fa';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
|
||||||
|
avgFitnessHistory.forEach((fitness, i) => {
|
||||||
|
const x = padding + (width - 2 * padding) * (i / maxGen);
|
||||||
|
const y = padding + (height - 2 * padding) * (1 - (fitness - minFitness) / fitnessRange);
|
||||||
|
|
||||||
|
if (i === 0) {
|
||||||
|
ctx.moveTo(x, y);
|
||||||
|
} else {
|
||||||
|
ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Legend
|
||||||
|
ctx.fillStyle = '#4ade80';
|
||||||
|
ctx.fillRect(width - 120, 10, 15, 3);
|
||||||
|
ctx.fillStyle = '#fff';
|
||||||
|
ctx.font = '12px sans-serif';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.fillText('Best', width - 100, 15);
|
||||||
|
|
||||||
|
ctx.fillStyle = '#60a5fa';
|
||||||
|
ctx.fillRect(width - 120, 25, 15, 3);
|
||||||
|
ctx.fillStyle = '#fff';
|
||||||
|
ctx.fillText('Average', width - 100, 30);
|
||||||
|
|
||||||
|
}, [bestFitnessHistory, avgFitnessHistory]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
width={600}
|
||||||
|
height={200}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: 'auto',
|
||||||
|
border: '1px solid #2a2a3e',
|
||||||
|
borderRadius: '8px',
|
||||||
|
background: '#0f0f1e',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
315
src/apps/BridgeBuilder/GeneticAlgo.ts
Normal file
315
src/apps/BridgeBuilder/GeneticAlgo.ts
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
// Genetic Algorithm for Bridge Evolution
|
||||||
|
import type { BridgeGenome, GAConfig } from './types';
|
||||||
|
import { DEFAULT_GA_CONFIG } from './types';
|
||||||
|
|
||||||
|
export class GeneticAlgorithm {
|
||||||
|
private config: GAConfig;
|
||||||
|
|
||||||
|
constructor(config: GAConfig = DEFAULT_GA_CONFIG) {
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
public createRandomGenome(minNodes = 15, maxNodes = 25): BridgeGenome {
|
||||||
|
for (let attempt = 0; attempt < 20; attempt++) {
|
||||||
|
const nodeCount = minNodes + Math.floor(Math.random() * (maxNodes - minNodes));
|
||||||
|
const nodes: { x: number; y: number }[] = [];
|
||||||
|
|
||||||
|
// Create nodes in a rough lattice/grid with jitter
|
||||||
|
const cols = 5;
|
||||||
|
const rows = Math.ceil(nodeCount / cols);
|
||||||
|
|
||||||
|
for (let i = 0; i < nodeCount; i++) {
|
||||||
|
const r = Math.floor(i / cols);
|
||||||
|
const c = i % cols;
|
||||||
|
|
||||||
|
const xBase = (cols > 1 ? c / (cols - 1) : 0.5);
|
||||||
|
const yBase = (rows > 1 ? r / (rows - 1) : 0.5);
|
||||||
|
|
||||||
|
const x = xBase * 0.8 + 0.1 + (Math.random() - 0.5) * 0.05;
|
||||||
|
const y = yBase * 0.4 + 0.3 + (Math.random() - 0.5) * 0.05;
|
||||||
|
|
||||||
|
nodes.push({
|
||||||
|
x: isNaN(x) ? 0.5 : Math.max(0.05, Math.min(0.95, x)),
|
||||||
|
y: isNaN(y) ? 0.5 : Math.max(0.1, Math.min(0.9, y))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const beams: { nodeA: number; nodeB: number }[] = [];
|
||||||
|
|
||||||
|
// 1. DENSE INTERNAL CONNECTIONS: Connect each node to its 3 nearest neighbors
|
||||||
|
for (let i = 0; i < nodes.length; i++) {
|
||||||
|
const distances = nodes.map((n, idx) => ({
|
||||||
|
idx,
|
||||||
|
dist: Math.pow(nodes[i].x - n.x, 2) + Math.pow(nodes[i].y - n.y, 2)
|
||||||
|
}))
|
||||||
|
.filter(d => d.idx !== i)
|
||||||
|
.sort((a, b) => a.dist - b.dist);
|
||||||
|
|
||||||
|
for (let j = 0; j < Math.min(3, distances.length); j++) {
|
||||||
|
const targetIdx = distances[j].idx;
|
||||||
|
if (!this.hasBeam(beams, i, targetIdx)) {
|
||||||
|
beams.push({ nodeA: i, nodeB: targetIdx });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. ANCHOR CONNECTIONS
|
||||||
|
const sortedByX = nodes.map((n, idx) => ({ n, idx })).sort((a,b) => a.n.x - b.n.x);
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
beams.push({ nodeA: -1, nodeB: sortedByX[i].idx });
|
||||||
|
beams.push({ nodeA: -2, nodeB: sortedByX[nodes.length - 1 - i].idx });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. LOAD BRACING
|
||||||
|
for (let i = 0; i < nodes.length; i++) {
|
||||||
|
if (nodes[i].x > 0.3 && nodes[i].x < 0.7 && Math.random() < 0.4) {
|
||||||
|
const target = Math.floor(Math.random() * nodes.length);
|
||||||
|
if (target !== i && !this.hasBeam(beams, i, target)) {
|
||||||
|
beams.push({ nodeA: i, nodeB: target });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const genome = { nodes, beams };
|
||||||
|
if (this.checkConnectivity(genome)) {
|
||||||
|
return genome;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: Just return whatever we got on last attempt if we failed to connect
|
||||||
|
return { nodes: [], beams: [] }; // Simulation will handle empty genome
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasBeam(beams: { nodeA: number; nodeB: number }[], a: number, b: number): boolean {
|
||||||
|
return beams.some(beam =>
|
||||||
|
(beam.nodeA === a && beam.nodeB === b) ||
|
||||||
|
(beam.nodeA === b && beam.nodeB === a)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public evolve(population: BridgeGenome[], fitnesses: number[]): BridgeGenome[] {
|
||||||
|
const newPopulation: BridgeGenome[] = [];
|
||||||
|
|
||||||
|
// Elitism - keep top performers
|
||||||
|
const sorted = population
|
||||||
|
.map((genome, i) => ({ genome, fitness: isNaN(fitnesses[i]) ? -1e9 : fitnesses[i] }))
|
||||||
|
.sort((a, b) => b.fitness - a.fitness);
|
||||||
|
|
||||||
|
for (let i = 0; i < this.config.eliteCount; i++) {
|
||||||
|
newPopulation.push(this.cloneGenome(sorted[i].genome));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill rest with offspring
|
||||||
|
while (newPopulation.length < population.length) {
|
||||||
|
const parent = this.tournamentSelect(population, fitnesses);
|
||||||
|
const offspring = this.mutate(this.cloneGenome(parent));
|
||||||
|
newPopulation.push(offspring);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newPopulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
private tournamentSelect(population: BridgeGenome[], fitnesses: number[]): BridgeGenome {
|
||||||
|
let bestIdx = Math.floor(Math.random() * population.length);
|
||||||
|
let bestFitness = fitnesses[bestIdx];
|
||||||
|
|
||||||
|
for (let i = 1; i < this.config.tournamentSize; i++) {
|
||||||
|
const idx = Math.floor(Math.random() * population.length);
|
||||||
|
if (fitnesses[idx] > bestFitness) {
|
||||||
|
bestIdx = idx;
|
||||||
|
bestFitness = fitnesses[idx];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return population[bestIdx];
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutate(genome: BridgeGenome): BridgeGenome {
|
||||||
|
const original = this.cloneGenome(genome);
|
||||||
|
|
||||||
|
if (Math.random() > this.config.mutationRate) {
|
||||||
|
return genome;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roll = Math.random();
|
||||||
|
let cumProb = 0;
|
||||||
|
|
||||||
|
let mutated = genome;
|
||||||
|
cumProb += this.config.addNodeProb;
|
||||||
|
if (roll < cumProb) mutated = this.addNode(genome);
|
||||||
|
else {
|
||||||
|
cumProb += this.config.removeNodeProb;
|
||||||
|
if (roll < cumProb) mutated = this.removeNode(genome);
|
||||||
|
else {
|
||||||
|
cumProb += this.config.moveNodeProb;
|
||||||
|
if (roll < cumProb) mutated = this.moveNode(genome);
|
||||||
|
else {
|
||||||
|
cumProb += this.config.addBeamProb;
|
||||||
|
if (roll < cumProb) mutated = this.addBeam(genome);
|
||||||
|
else mutated = this.removeBeam(genome);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connectivity Guard: If mutation broke the bridge, try to fix it or revert
|
||||||
|
if (!this.checkConnectivity(mutated)) {
|
||||||
|
// Try one quick repair attempt
|
||||||
|
this.addBeam(mutated);
|
||||||
|
if (!this.checkConnectivity(mutated)) {
|
||||||
|
return original; // Revert if still broken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mutated;
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkConnectivity(genome: BridgeGenome): boolean {
|
||||||
|
if (genome.nodes.length === 0) return false;
|
||||||
|
|
||||||
|
// Check path from left (-1) -> any center node -> right (-2)
|
||||||
|
const adj = new Map<number, number[]>();
|
||||||
|
genome.beams.forEach(b => {
|
||||||
|
if (!adj.has(b.nodeA)) adj.set(b.nodeA, []);
|
||||||
|
if (!adj.has(b.nodeB)) adj.set(b.nodeB, []);
|
||||||
|
adj.get(b.nodeA)!.push(b.nodeB);
|
||||||
|
adj.get(b.nodeB)!.push(b.nodeA);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!adj.has(-1) || !adj.has(-2)) return false;
|
||||||
|
|
||||||
|
// 1. BFS to find all nodes reachable from left anchor
|
||||||
|
const reachableFromLeft = this.getReachable(adj, -1);
|
||||||
|
|
||||||
|
// 2. BFS to find all nodes reachable from right anchor
|
||||||
|
const reachableFromRight = this.getReachable(adj, -2);
|
||||||
|
|
||||||
|
// 3. Find intersection (nodes on a path between anchors)
|
||||||
|
const bridgePathNodes = Array.from(reachableFromLeft).filter(id => reachableFromRight.has(id));
|
||||||
|
|
||||||
|
// 4. Check if any of these nodes are in the "load zone" (center of bridge)
|
||||||
|
// Relative x between 0.3 and 0.7
|
||||||
|
const hasCentralNode = bridgePathNodes.some(id => {
|
||||||
|
if (id < 0) return false;
|
||||||
|
const x = genome.nodes[id].x;
|
||||||
|
return x > 0.3 && x < 0.7;
|
||||||
|
});
|
||||||
|
|
||||||
|
return hasCentralNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getReachable(adj: Map<number, number[]>, start: number): Set<number> {
|
||||||
|
const visited = new Set<number>();
|
||||||
|
const queue = [start];
|
||||||
|
visited.add(start);
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const u = queue.shift()!;
|
||||||
|
const neighbors = adj.get(u) || [];
|
||||||
|
for (const v of neighbors) {
|
||||||
|
if (!visited.has(v)) {
|
||||||
|
visited.add(v);
|
||||||
|
queue.push(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return visited;
|
||||||
|
}
|
||||||
|
|
||||||
|
private addNode(genome: BridgeGenome): BridgeGenome {
|
||||||
|
if (genome.nodes.length >= 50) return genome; // Updated to match DEFAULT_BRIDGE_CONFIG
|
||||||
|
|
||||||
|
genome.nodes.push({
|
||||||
|
x: Math.random(),
|
||||||
|
y: Math.random() * 0.5 + 0.2,
|
||||||
|
});
|
||||||
|
|
||||||
|
return genome;
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeNode(genome: BridgeGenome): BridgeGenome {
|
||||||
|
if (genome.nodes.length <= 5) return genome; // Keep a minimum for lattice survival
|
||||||
|
|
||||||
|
const idx = Math.floor(Math.random() * genome.nodes.length);
|
||||||
|
genome.nodes.splice(idx, 1);
|
||||||
|
|
||||||
|
// Remove beams connected to this node
|
||||||
|
genome.beams = genome.beams.filter(b =>
|
||||||
|
b.nodeA !== idx && b.nodeB !== idx
|
||||||
|
);
|
||||||
|
|
||||||
|
// Adjust indices for remaining beams
|
||||||
|
genome.beams.forEach(b => {
|
||||||
|
if (b.nodeA > idx) b.nodeA--;
|
||||||
|
if (b.nodeB > idx) b.nodeB--;
|
||||||
|
});
|
||||||
|
|
||||||
|
return genome;
|
||||||
|
}
|
||||||
|
|
||||||
|
private moveNode(genome: BridgeGenome): BridgeGenome {
|
||||||
|
if (genome.nodes.length === 0) return genome;
|
||||||
|
|
||||||
|
const idx = Math.floor(Math.random() * genome.nodes.length);
|
||||||
|
const node = genome.nodes[idx];
|
||||||
|
|
||||||
|
// Small perturbation
|
||||||
|
node.x = Math.max(0, Math.min(1, node.x + (Math.random() - 0.5) * 0.1));
|
||||||
|
node.y = Math.max(0.1, Math.min(0.9, node.y + (Math.random() - 0.5) * 0.1));
|
||||||
|
|
||||||
|
return genome;
|
||||||
|
}
|
||||||
|
|
||||||
|
private addBeam(genome: BridgeGenome): BridgeGenome {
|
||||||
|
if (genome.beams.length >= 100) return genome; // Updated to match DEFAULT_BRIDGE_CONFIG
|
||||||
|
if (genome.nodes.length < 2) return genome;
|
||||||
|
|
||||||
|
// Try to connect disconnected subgraphs or anchors
|
||||||
|
const nodes = genome.nodes;
|
||||||
|
|
||||||
|
// Choose starting node
|
||||||
|
const a = Math.random() < 0.2 ? (Math.random() < 0.5 ? -1 : -2) : Math.floor(Math.random() * nodes.length);
|
||||||
|
|
||||||
|
// Find random target
|
||||||
|
let bestTarget = -1;
|
||||||
|
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const b = Math.floor(Math.random() * nodes.length);
|
||||||
|
if (a === b) continue;
|
||||||
|
if (this.hasBeam(genome.beams, a, b)) continue;
|
||||||
|
|
||||||
|
bestTarget = b;
|
||||||
|
break; // Simple random for now
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestTarget !== -1) {
|
||||||
|
genome.beams.push({ nodeA: a, nodeB: bestTarget });
|
||||||
|
}
|
||||||
|
|
||||||
|
return genome;
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeBeam(genome: BridgeGenome): BridgeGenome {
|
||||||
|
if (genome.beams.length === 0) return genome;
|
||||||
|
|
||||||
|
// Bias toward removing internal beams, protect anchors a bit
|
||||||
|
const internalBeams = genome.beams.filter(b => b.nodeA >= 0 && b.nodeB >= 0);
|
||||||
|
const targetArray = (internalBeams.length > 5 && Math.random() < 0.8) ? internalBeams : genome.beams;
|
||||||
|
|
||||||
|
const beamToRemove = targetArray[Math.floor(Math.random() * targetArray.length)];
|
||||||
|
const idx = genome.beams.indexOf(beamToRemove);
|
||||||
|
if (idx !== -1) {
|
||||||
|
// Don't remove if it's the last anchor connection?
|
||||||
|
// We have the checkConnectivity guard in mutate() anyway.
|
||||||
|
genome.beams.splice(idx, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return genome;
|
||||||
|
}
|
||||||
|
|
||||||
|
private cloneGenome(genome: BridgeGenome): BridgeGenome {
|
||||||
|
return {
|
||||||
|
nodes: genome.nodes.map(n => ({ ...n })),
|
||||||
|
beams: genome.beams.map(b => ({ ...b })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
79
src/apps/BridgeBuilder/e2e.test.ts
Normal file
79
src/apps/BridgeBuilder/e2e.test.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
// E2E Test for Bridge Builder
|
||||||
|
import { describe, it, expect } from 'bun:test';
|
||||||
|
import { BridgeSimulation } from './BridgeSimulation';
|
||||||
|
import { GeneticAlgorithm } from './GeneticAlgo';
|
||||||
|
import { DEFAULT_BRIDGE_CONFIG, DEFAULT_SIM_CONFIG, DEFAULT_GA_CONFIG } from './types';
|
||||||
|
import type { BridgeGenome } from './types';
|
||||||
|
|
||||||
|
describe('Bridge Builder E2E', () => {
|
||||||
|
it('should evolve bridges that survive longer than 12 steps', () => {
|
||||||
|
const ga = new GeneticAlgorithm(DEFAULT_GA_CONFIG);
|
||||||
|
|
||||||
|
// Create initial population
|
||||||
|
const populationSize = 10;
|
||||||
|
let population: BridgeGenome[] = [];
|
||||||
|
for (let i = 0; i < populationSize; i++) {
|
||||||
|
population.push(ga.createRandomGenome(3, 8));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n=== Bridge Builder E2E Test ===\n');
|
||||||
|
|
||||||
|
// Check first genome structure
|
||||||
|
console.log('First genome sample:');
|
||||||
|
console.log(` Nodes: ${population[0].nodes.length}`, population[0].nodes.slice(0, 3));
|
||||||
|
console.log(` Beams: ${population[0].beams.length}`, population[0].beams.slice(0, 5));
|
||||||
|
|
||||||
|
// Evolve for 50 generations
|
||||||
|
for (let gen = 1; gen <= 50; gen++) {
|
||||||
|
const fitnesses: number[] = [];
|
||||||
|
const steps: number[] = [];
|
||||||
|
|
||||||
|
// Evaluate each genome
|
||||||
|
for (const genome of population) {
|
||||||
|
const sim = new BridgeSimulation(genome, DEFAULT_BRIDGE_CONFIG, DEFAULT_SIM_CONFIG);
|
||||||
|
sim.run(DEFAULT_SIM_CONFIG.maxSteps);
|
||||||
|
|
||||||
|
const result = sim.getResult();
|
||||||
|
fitnesses.push(result.fitness);
|
||||||
|
steps.push(result.stepsSupported);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxFitness = Math.max(...fitnesses);
|
||||||
|
const avgFitness = fitnesses.reduce((a, b) => a + b, 0) / fitnesses.length;
|
||||||
|
const bestIdx = fitnesses.indexOf(maxFitness);
|
||||||
|
const maxSteps = Math.max(...steps);
|
||||||
|
const avgSteps = steps.reduce((a, b) => a + b, 0) / steps.length;
|
||||||
|
|
||||||
|
if (gen % 10 === 0 || gen === 1) {
|
||||||
|
console.log(`Gen ${gen}:`);
|
||||||
|
console.log(` Fitness: ${avgFitness.toFixed(1)} (best: ${maxFitness.toFixed(1)})`);
|
||||||
|
console.log(` Steps: ${avgSteps.toFixed(1)} (best: ${maxSteps})`);
|
||||||
|
console.log(` Best genome: ${population[bestIdx].nodes.length} nodes, ${population[bestIdx].beams.length} beams`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evolve population
|
||||||
|
population = ga.evolve(population, fitnesses);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final check
|
||||||
|
const finalFitnesses = population.map(genome => {
|
||||||
|
const sim = new BridgeSimulation(genome, DEFAULT_BRIDGE_CONFIG, DEFAULT_SIM_CONFIG);
|
||||||
|
sim.run(DEFAULT_SIM_CONFIG.maxSteps);
|
||||||
|
return sim.getResult();
|
||||||
|
});
|
||||||
|
|
||||||
|
const bestFinalResult = finalFitnesses.reduce((best, curr) =>
|
||||||
|
curr.fitness > best.fitness ? curr : best
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('\n=== Final Results ===');
|
||||||
|
console.log(`Best fitness: ${bestFinalResult.fitness.toFixed(1)}`);
|
||||||
|
console.log(`Steps survived: ${bestFinalResult.stepsSupported} / ${DEFAULT_SIM_CONFIG.maxSteps}`);
|
||||||
|
console.log(`Collapsed: ${bestFinalResult.collapsed}`);
|
||||||
|
console.log(`Beams: ${bestFinalResult.beamCount}`);
|
||||||
|
|
||||||
|
// Test assertions
|
||||||
|
expect(bestFinalResult.stepsSupported).toBeGreaterThan(12);
|
||||||
|
expect(bestFinalResult.fitness).toBeGreaterThan(1200);
|
||||||
|
});
|
||||||
|
});
|
||||||
33
src/apps/BridgeBuilder/manual_test.ts
Normal file
33
src/apps/BridgeBuilder/manual_test.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// Simple manual test
|
||||||
|
import { BridgeSimulation } from './BridgeSimulation';
|
||||||
|
import { DEFAULT_BRIDGE_CONFIG, DEFAULT_SIM_CONFIG } from './types';
|
||||||
|
|
||||||
|
// Triangle bridge - simplest possible
|
||||||
|
const genome = {
|
||||||
|
nodes: [
|
||||||
|
{ x: 0.5, y: 0.3 }, // Center node below anchors
|
||||||
|
],
|
||||||
|
beams: [
|
||||||
|
{ nodeA: -1, nodeB: 0 }, // Left anchor to center
|
||||||
|
{ nodeA: -2, nodeB: 0 }, // Right anchor to center
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('\n=== Manual Triangle Test ===\n');
|
||||||
|
console.log('Genome:', genome);
|
||||||
|
|
||||||
|
const sim = new BridgeSimulation(genome, DEFAULT_BRIDGE_CONFIG, DEFAULT_SIM_CONFIG);
|
||||||
|
|
||||||
|
// Run for 100 steps
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
sim.update();
|
||||||
|
if (i < 20 || i === 99) {
|
||||||
|
console.log(`Step ${i + 1}: loadY=${sim['load'].position.y.toFixed(1)}, collapsed=${sim['collapsed']}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = sim.getResult();
|
||||||
|
console.log('\n=== Result ===');
|
||||||
|
console.log(`Steps: ${result.stepsSupported}`);
|
||||||
|
console.log(`Fitness: ${result.fitness.toFixed(1)}`);
|
||||||
|
console.log(`Collapsed: ${result.collapsed}`);
|
||||||
132
src/apps/BridgeBuilder/training.worker.ts
Normal file
132
src/apps/BridgeBuilder/training.worker.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
// Training Worker for Bridge Evolution
|
||||||
|
// @ts-ignore
|
||||||
|
import decomp from 'poly-decomp';
|
||||||
|
import Matter from 'matter-js';
|
||||||
|
Matter.Common.setDecomp(decomp);
|
||||||
|
|
||||||
|
import { BridgeSimulation } from './BridgeSimulation';
|
||||||
|
import { GeneticAlgorithm } from './GeneticAlgo';
|
||||||
|
import type { BridgeGenome, BridgeConfig, SimulationConfig, GAConfig } from './types';
|
||||||
|
|
||||||
|
interface WorkerConfig {
|
||||||
|
bridgeConfig: BridgeConfig;
|
||||||
|
simConfig: SimulationConfig;
|
||||||
|
gaConfig: GAConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
let population: BridgeGenome[] = [];
|
||||||
|
let ga: GeneticAlgorithm;
|
||||||
|
let config: WorkerConfig;
|
||||||
|
let running = false;
|
||||||
|
let generation = 0;
|
||||||
|
|
||||||
|
self.onmessage = (e: MessageEvent) => {
|
||||||
|
const { type, payload } = e.data;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'start':
|
||||||
|
config = payload;
|
||||||
|
ga = new GeneticAlgorithm(config.gaConfig);
|
||||||
|
initializePopulation();
|
||||||
|
running = true;
|
||||||
|
runGeneration();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'stop':
|
||||||
|
running = false;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'reset':
|
||||||
|
generation = 0;
|
||||||
|
initializePopulation();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function initializePopulation() {
|
||||||
|
population = [];
|
||||||
|
for (let i = 0; i < config.simConfig.populationSize; i++) {
|
||||||
|
population.push(ga.createRandomGenome(15, 25));
|
||||||
|
}
|
||||||
|
generation = 0;
|
||||||
|
|
||||||
|
// Debug first genome
|
||||||
|
const first = population[0];
|
||||||
|
console.log('[Init] First random genome:', {
|
||||||
|
nodes: first.nodes.length,
|
||||||
|
beams: first.beams.length,
|
||||||
|
sampleNode: first.nodes[0],
|
||||||
|
sampleBeams: first.beams.slice(0, 3)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runGeneration() {
|
||||||
|
if (!running) return;
|
||||||
|
|
||||||
|
generation++;
|
||||||
|
|
||||||
|
// Evaluate all genomes
|
||||||
|
const fitnesses: number[] = [];
|
||||||
|
const results: any[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < population.length; i++) {
|
||||||
|
const genome = population[i];
|
||||||
|
const sim = new BridgeSimulation(genome, config.bridgeConfig, config.simConfig);
|
||||||
|
|
||||||
|
// Run simulation
|
||||||
|
sim.run(config.simConfig.maxSteps);
|
||||||
|
|
||||||
|
const result = sim.getResult();
|
||||||
|
fitnesses.push(result.fitness);
|
||||||
|
results.push(result);
|
||||||
|
|
||||||
|
// Debug first genome of first 3 generations
|
||||||
|
if (generation <= 3 && i === 0) {
|
||||||
|
console.log(`[Gen ${generation}] First genome:`, {
|
||||||
|
nodes: genome.nodes.length,
|
||||||
|
beams: genome.beams.length,
|
||||||
|
result: {
|
||||||
|
fitness: result.fitness.toFixed(1),
|
||||||
|
steps: result.stepsSupported,
|
||||||
|
avgHeight: result.avgLoadHeight.toFixed(1),
|
||||||
|
collapsed: result.collapsed
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find best
|
||||||
|
const validFitnesses = fitnesses.filter(f => !isNaN(f));
|
||||||
|
const maxFitness = validFitnesses.length > 0 ? Math.max(...validFitnesses) : 0;
|
||||||
|
const minFitness = validFitnesses.length > 0 ? Math.min(...validFitnesses) : 0;
|
||||||
|
const avgFitness = validFitnesses.length > 0 ? validFitnesses.reduce((a, b) => a + b, 0) / validFitnesses.length : 0;
|
||||||
|
|
||||||
|
let bestIdx = fitnesses.indexOf(maxFitness);
|
||||||
|
if (bestIdx === -1) bestIdx = 0; // Fallback to first if all crashed/NaN
|
||||||
|
|
||||||
|
const bestGenome = population[bestIdx];
|
||||||
|
const bestResult = results[bestIdx] || { stepsSupported: 0, avgLoadHeight: 0 };
|
||||||
|
|
||||||
|
// Debug log every 10 generations
|
||||||
|
if (generation % 10 === 0 || generation <= 3) {
|
||||||
|
console.log(`[Gen ${generation}] Fitness: ${minFitness.toFixed(1)}-${maxFitness.toFixed(1)} (avg: ${avgFitness.toFixed(1)})`);
|
||||||
|
console.log(` Best: nodes=${bestGenome.nodes.length}, beams=${bestGenome.beams.length}, steps=${bestResult.stepsSupported}, height=${bestResult.avgLoadHeight.toFixed(1)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send progress update
|
||||||
|
self.postMessage({
|
||||||
|
type: 'progress',
|
||||||
|
payload: {
|
||||||
|
generation,
|
||||||
|
bestFitness: maxFitness,
|
||||||
|
avgFitness,
|
||||||
|
bestGenome,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Evolve population
|
||||||
|
population = ga.evolve(population, fitnesses);
|
||||||
|
|
||||||
|
// Continue to next generation
|
||||||
|
setTimeout(() => runGeneration(), 0);
|
||||||
|
}
|
||||||
83
src/apps/BridgeBuilder/types.ts
Normal file
83
src/apps/BridgeBuilder/types.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
// Bridge Builder Types
|
||||||
|
|
||||||
|
export interface BridgeGenome {
|
||||||
|
nodes: { x: number; y: number }[]; // Joint positions (relative coords)
|
||||||
|
beams: { nodeA: number; nodeB: number }[]; // Beam connections (node indices)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BridgeConfig {
|
||||||
|
spanWidth: number; // Width of gap to span
|
||||||
|
anchorHeight: number; // Y position of anchor points
|
||||||
|
maxNodes: number; // Maximum nodes allowed in genome
|
||||||
|
maxBeams: number; // Maximum beams allowed in genome
|
||||||
|
beamStrength: number; // Force threshold before breaking (N)
|
||||||
|
beamStiffness: number; // Constraint stiffness (0-1)
|
||||||
|
loadMass: number; // Mass of the load to support (kg)
|
||||||
|
nodeRadius: number; // Radius of node bodies
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SimulationConfig {
|
||||||
|
populationSize: number;
|
||||||
|
maxSteps: number; // Max physics steps per evaluation
|
||||||
|
physicsIterations: number; // Matter.js constraint iterations
|
||||||
|
timeStep: number; // Physics time step (ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GAConfig {
|
||||||
|
mutationRate: number; // Probability of mutation per genome
|
||||||
|
eliteCount: number; // Number of top performers to preserve
|
||||||
|
tournamentSize: number; // Tournament selection size
|
||||||
|
|
||||||
|
// Mutation operation probabilities (should sum to ~1.0)
|
||||||
|
addNodeProb: number;
|
||||||
|
removeNodeProb: number;
|
||||||
|
moveNodeProb: number;
|
||||||
|
addBeamProb: number;
|
||||||
|
removeBeamProb: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BeamForce {
|
||||||
|
nodeA: number;
|
||||||
|
nodeB: number;
|
||||||
|
force: number; // Positive = tension, Negative = compression
|
||||||
|
broken: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SimulationResult {
|
||||||
|
fitness: number;
|
||||||
|
stepsSupported: number;
|
||||||
|
avgLoadHeight: number;
|
||||||
|
beamCount: number;
|
||||||
|
maxStress: number;
|
||||||
|
collapsed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default configurations
|
||||||
|
export const DEFAULT_BRIDGE_CONFIG: BridgeConfig = {
|
||||||
|
spanWidth: 600,
|
||||||
|
anchorHeight: 200,
|
||||||
|
maxNodes: 50,
|
||||||
|
maxBeams: 100,
|
||||||
|
beamStrength: 5000, // Increased for better initial stability
|
||||||
|
beamStiffness: 1.0, // RIGID - no stretching!
|
||||||
|
loadMass: 10, // 10kg - significant challenge
|
||||||
|
nodeRadius: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_SIM_CONFIG: SimulationConfig = {
|
||||||
|
populationSize: 50,
|
||||||
|
maxSteps: 600, // 10 seconds at 60fps
|
||||||
|
physicsIterations: 10,
|
||||||
|
timeStep: 1000 / 60,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_GA_CONFIG: GAConfig = {
|
||||||
|
mutationRate: 0.8,
|
||||||
|
eliteCount: 5,
|
||||||
|
tournamentSize: 3,
|
||||||
|
addNodeProb: 0.2,
|
||||||
|
removeNodeProb: 0.1,
|
||||||
|
moveNodeProb: 0.3,
|
||||||
|
addBeamProb: 0.3,
|
||||||
|
removeBeamProb: 0.1,
|
||||||
|
};
|
||||||
101
src/apps/BridgeBuilder/useEvolutionWorker.ts
Normal file
101
src/apps/BridgeBuilder/useEvolutionWorker.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import type { BridgeGenome, BridgeConfig, SimulationConfig, GAConfig } from './types';
|
||||||
|
import TrainingWorker from './training.worker.ts?worker';
|
||||||
|
|
||||||
|
export function useEvolutionWorker(
|
||||||
|
bridgeConfig: BridgeConfig,
|
||||||
|
simConfig: SimulationConfig,
|
||||||
|
gaConfig: GAConfig
|
||||||
|
) {
|
||||||
|
const [generation, setGeneration] = useState(0);
|
||||||
|
const [bestFitness, setBestFitness] = useState(0);
|
||||||
|
const [avgFitness, setAvgFitness] = useState(0);
|
||||||
|
const [bestGenome, setBestGenome] = useState<BridgeGenome | null>(null);
|
||||||
|
const [isTraining, setIsTraining] = useState(false);
|
||||||
|
|
||||||
|
// Fitness history for graphing
|
||||||
|
const [bestFitnessHistory, setBestFitnessHistory] = useState<number[]>([]);
|
||||||
|
const [avgFitnessHistory, setAvgFitnessHistory] = useState<number[]>([]);
|
||||||
|
|
||||||
|
const workerRef = useRef<Worker | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Create worker
|
||||||
|
workerRef.current = new TrainingWorker();
|
||||||
|
|
||||||
|
// Setup message handler
|
||||||
|
if (workerRef.current) {
|
||||||
|
workerRef.current.onmessage = (e: MessageEvent) => {
|
||||||
|
const { type, payload } = e.data;
|
||||||
|
|
||||||
|
if (type === 'progress') {
|
||||||
|
console.log('[useEvolutionWorker] Received progress:', {
|
||||||
|
gen: payload.generation,
|
||||||
|
fitness: payload.bestFitness,
|
||||||
|
genomeNodes: payload.bestGenome?.nodes.length,
|
||||||
|
genomeBeams: payload.bestGenome?.beams.length
|
||||||
|
});
|
||||||
|
setGeneration(payload.generation);
|
||||||
|
setBestFitness(payload.bestFitness);
|
||||||
|
setAvgFitness(payload.avgFitness);
|
||||||
|
setBestGenome(payload.bestGenome);
|
||||||
|
|
||||||
|
// Update fitness history
|
||||||
|
setBestFitnessHistory(prev => [...prev, payload.bestFitness]);
|
||||||
|
setAvgFitnessHistory(prev => [...prev, payload.avgFitness]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
workerRef.current?.terminate();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startTraining = () => {
|
||||||
|
if (!workerRef.current) return;
|
||||||
|
|
||||||
|
workerRef.current.postMessage({
|
||||||
|
type: 'start',
|
||||||
|
payload: {
|
||||||
|
bridgeConfig,
|
||||||
|
simConfig,
|
||||||
|
gaConfig,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsTraining(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopTraining = () => {
|
||||||
|
if (!workerRef.current) return;
|
||||||
|
|
||||||
|
workerRef.current.postMessage({ type: 'stop' });
|
||||||
|
setIsTraining(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
if (!workerRef.current) return;
|
||||||
|
|
||||||
|
workerRef.current.postMessage({ type: 'reset' });
|
||||||
|
setGeneration(0);
|
||||||
|
setBestFitness(0);
|
||||||
|
setAvgFitness(0);
|
||||||
|
setBestGenome(null);
|
||||||
|
setBestFitnessHistory([]);
|
||||||
|
setAvgFitnessHistory([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
generation,
|
||||||
|
bestFitness,
|
||||||
|
avgFitness,
|
||||||
|
bestGenome,
|
||||||
|
isTraining,
|
||||||
|
bestFitnessHistory,
|
||||||
|
avgFitnessHistory,
|
||||||
|
startTraining,
|
||||||
|
stopTraining,
|
||||||
|
reset,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
import './Sidebar.css';
|
import './Sidebar.css';
|
||||||
|
|
||||||
export type AppId = 'image-approx' | 'snake-ai' | 'rogue-gen' | 'neat-arena' | 'lunar-lander' | 'self-driving-car';
|
export type AppId = 'image-approx' | 'snake-ai' | 'rogue-gen' | 'neat-arena' | 'lunar-lander' | 'self-driving-car' | 'bridge-builder' | 'asteroids-ai';
|
||||||
|
|
||||||
export interface AppInfo {
|
export interface AppInfo {
|
||||||
id: AppId;
|
id: AppId;
|
||||||
@@ -47,6 +47,18 @@ export const APPS: AppInfo[] = [
|
|||||||
name: 'Self-Driving Car',
|
name: 'Self-Driving Car',
|
||||||
description: 'Evolve cars to navigate a track',
|
description: 'Evolve cars to navigate a track',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'bridge-builder',
|
||||||
|
path: '/bridge-builder',
|
||||||
|
name: 'Bridge Builder',
|
||||||
|
description: 'Evolve bridge structures with stress visualization',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'asteroids-ai',
|
||||||
|
path: '/asteroids-ai',
|
||||||
|
name: 'Asteroids AI',
|
||||||
|
description: 'Evolve strategies to shoot asteroids and avoid collisions',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Sidebar() {
|
export default function Sidebar() {
|
||||||
|
|||||||
Reference in New Issue
Block a user