From 373158fb3d155eb8f163def7e2e1906e622d68a3 Mon Sep 17 00:00:00 2001 From: Peter Stockings Date: Sat, 17 Jan 2026 10:59:57 +1100 Subject: [PATCH] Add bridge builder (not working) and asteroids --- README.md | 1 + src/App.tsx | 4 + src/apps/AsteroidsAI/Asteroids.css | 125 ++++++ src/apps/AsteroidsAI/AsteroidsApp.tsx | 82 ++++ src/apps/AsteroidsAI/AsteroidsScene.ts | 288 ++++++++++++ src/apps/AsteroidsAI/AsteroidsSimulation.ts | 420 ++++++++++++++++++ src/apps/AsteroidsAI/ConfigPanel.css | 127 ++++++ src/apps/AsteroidsAI/ConfigPanel.tsx | 265 +++++++++++ src/apps/AsteroidsAI/DenseNetwork.ts | 69 +++ src/apps/AsteroidsAI/GeneticAlgo.ts | 118 +++++ src/apps/AsteroidsAI/config.ts | 86 ++++ src/apps/AsteroidsAI/debug.test.ts | 69 +++ src/apps/AsteroidsAI/destruction.test.ts | 60 +++ src/apps/AsteroidsAI/fitnessConfig.ts | 43 ++ src/apps/AsteroidsAI/training.worker.ts | 119 +++++ src/apps/AsteroidsAI/useEvolutionWorker.ts | 82 ++++ src/apps/BridgeBuilder/.reload | 2 + src/apps/BridgeBuilder/BridgeBuilder.css | 240 ++++++++++ src/apps/BridgeBuilder/BridgeBuilderApp.tsx | 301 +++++++++++++ src/apps/BridgeBuilder/BridgeScene.ts | 206 +++++++++ src/apps/BridgeBuilder/BridgeSimulation.ts | 434 +++++++++++++++++++ src/apps/BridgeBuilder/FitnessGraph.tsx | 120 +++++ src/apps/BridgeBuilder/GeneticAlgo.ts | 315 ++++++++++++++ src/apps/BridgeBuilder/e2e.test.ts | 79 ++++ src/apps/BridgeBuilder/manual_test.ts | 33 ++ src/apps/BridgeBuilder/training.worker.ts | 132 ++++++ src/apps/BridgeBuilder/types.ts | 83 ++++ src/apps/BridgeBuilder/useEvolutionWorker.ts | 101 +++++ src/components/Sidebar.tsx | 14 +- 29 files changed, 4017 insertions(+), 1 deletion(-) create mode 100644 src/apps/AsteroidsAI/Asteroids.css create mode 100644 src/apps/AsteroidsAI/AsteroidsApp.tsx create mode 100644 src/apps/AsteroidsAI/AsteroidsScene.ts create mode 100644 src/apps/AsteroidsAI/AsteroidsSimulation.ts create mode 100644 src/apps/AsteroidsAI/ConfigPanel.css create mode 100644 src/apps/AsteroidsAI/ConfigPanel.tsx create mode 100644 src/apps/AsteroidsAI/DenseNetwork.ts create mode 100644 src/apps/AsteroidsAI/GeneticAlgo.ts create mode 100644 src/apps/AsteroidsAI/config.ts create mode 100644 src/apps/AsteroidsAI/debug.test.ts create mode 100644 src/apps/AsteroidsAI/destruction.test.ts create mode 100644 src/apps/AsteroidsAI/fitnessConfig.ts create mode 100644 src/apps/AsteroidsAI/training.worker.ts create mode 100644 src/apps/AsteroidsAI/useEvolutionWorker.ts create mode 100644 src/apps/BridgeBuilder/.reload create mode 100644 src/apps/BridgeBuilder/BridgeBuilder.css create mode 100644 src/apps/BridgeBuilder/BridgeBuilderApp.tsx create mode 100644 src/apps/BridgeBuilder/BridgeScene.ts create mode 100644 src/apps/BridgeBuilder/BridgeSimulation.ts create mode 100644 src/apps/BridgeBuilder/FitnessGraph.tsx create mode 100644 src/apps/BridgeBuilder/GeneticAlgo.ts create mode 100644 src/apps/BridgeBuilder/e2e.test.ts create mode 100644 src/apps/BridgeBuilder/manual_test.ts create mode 100644 src/apps/BridgeBuilder/training.worker.ts create mode 100644 src/apps/BridgeBuilder/types.ts create mode 100644 src/apps/BridgeBuilder/useEvolutionWorker.ts diff --git a/README.md b/README.md index e0cd580..4c5ad01 100644 --- a/README.md +++ b/README.md @@ -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 - **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 +- **Bridge Builder**: Evolve bridge structures with stress visualization and physics - **Image Approximation**: Genetic algorithm that evolves shapes to approximate a target image - **NEAT Arena**: Neural evolution of augmenting topologies in a competitive environment - **Rogue Gen**: Procedural dungeon generation using evolutionary techniques diff --git a/src/App.tsx b/src/App.tsx index d012b55..4d6bd62 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,8 @@ import RogueGenApp from './apps/RogueGen/RogueGenApp'; import NeatArena from './apps/NeatArena/NeatArena'; import LunarLanderApp from './apps/LunarLander/LunarLanderApp'; import { SelfDrivingCarApp } from './apps/SelfDrivingCar/SelfDrivingCarApp'; +import BridgeBuilderApp from './apps/BridgeBuilder/BridgeBuilderApp'; +import AsteroidsAI from './apps/AsteroidsAI/AsteroidsApp'; import './App.css'; function App() { @@ -21,6 +23,8 @@ function App() { } /> } /> } /> + } /> + } /> App not found} /> diff --git a/src/apps/AsteroidsAI/Asteroids.css b/src/apps/AsteroidsAI/Asteroids.css new file mode 100644 index 0000000..7e512a4 --- /dev/null +++ b/src/apps/AsteroidsAI/Asteroids.css @@ -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; +} \ No newline at end of file diff --git a/src/apps/AsteroidsAI/AsteroidsApp.tsx b/src/apps/AsteroidsAI/AsteroidsApp.tsx new file mode 100644 index 0000000..2cdfa45 --- /dev/null +++ b/src/apps/AsteroidsAI/AsteroidsApp.tsx @@ -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(null); + const phaserGameRef = useRef(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 ( + +
+
+
+ + +
+ +
+ + + +
+
+ + + +
+ +
+ +
+
+
+
+ + ); +} + +function StatCard({ label, value, highlight = false }: { label: string, value: string | number, highlight?: boolean }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/src/apps/AsteroidsAI/AsteroidsScene.ts b/src/apps/AsteroidsAI/AsteroidsScene.ts new file mode 100644 index 0000000..544cd04 --- /dev/null +++ b/src/apps/AsteroidsAI/AsteroidsScene.ts @@ -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; +} diff --git a/src/apps/AsteroidsAI/AsteroidsSimulation.ts b/src/apps/AsteroidsAI/AsteroidsSimulation.ts new file mode 100644 index 0000000..25a3e7a --- /dev/null +++ b/src/apps/AsteroidsAI/AsteroidsSimulation.ts @@ -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 = 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) { + 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; + } +} diff --git a/src/apps/AsteroidsAI/ConfigPanel.css b/src/apps/AsteroidsAI/ConfigPanel.css new file mode 100644 index 0000000..6a9c519 --- /dev/null +++ b/src/apps/AsteroidsAI/ConfigPanel.css @@ -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); +} \ No newline at end of file diff --git a/src/apps/AsteroidsAI/ConfigPanel.tsx b/src/apps/AsteroidsAI/ConfigPanel.tsx new file mode 100644 index 0000000..af729d9 --- /dev/null +++ b/src/apps/AsteroidsAI/ConfigPanel.tsx @@ -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 ( +
+ + + {isOpen && ( +
+
+

🧠 Neural Network

+
+ + handleChange('NUM_RAYCASTS', parseInt(e.target.value))} + min="4" max="32" step="4" + /> +
+
+ + [{CONFIG.HIDDEN_LAYERS.join(', ')}] +
+
+ +
+

🧬 Genetic Algorithm

+
+ + handleChange('POPULATION_SIZE', parseInt(e.target.value))} + min="10" max="500" step="10" + /> +
+
+ + handleChange('MUTATION_RATE', parseFloat(e.target.value))} + min="0" max="1" step="0.05" + /> +
+
+ + handleChange('MUTATION_SCALE', parseFloat(e.target.value))} + min="0" max="2" step="0.1" + /> +
+
+ + handleChange('ELITE_COUNT', parseInt(e.target.value))} + min="0" max="20" + /> +
+
+ + handleChange('SCENARIOS_PER_GENOME', parseInt(e.target.value))} + min="1" max="10" + /> +
+
+ +
+

🚀 Ship Properties

+
+ + handleChange('SHIP_SIZE', parseInt(e.target.value))} + min="5" max="30" + /> +
+
+ + handleChange('ROTATION_SPEED', parseFloat(e.target.value))} + min="0.01" max="0.5" step="0.01" + /> +
+
+ + handleChange('THRUST_FORCE', parseFloat(e.target.value))} + min="0.0001" max="0.002" step="0.0001" + /> +
+
+ +
+

💥 Bullet Properties

+
+ + handleChange('BULLET_SPEED', parseInt(e.target.value))} + min="1" max="30" + /> +
+
+ + handleChange('BULLET_LIFETIME', parseInt(e.target.value))} + min="10" max="120" step="10" + /> +
+
+ + handleChange('SHOOT_COOLDOWN', parseInt(e.target.value))} + min="1" max="30" + /> +
+
+ +
+

☄️ Asteroid Properties

+
+ + handleChange('ASTEROID_INITIAL_COUNT', parseInt(e.target.value))} + min="1" max="10" + /> +
+
+ + handleChange('ASTEROID_SIZE_LARGE', parseInt(e.target.value))} + min="20" max="100" step="5" + /> +
+
+ + handleChange('ASTEROID_SIZE_MEDIUM', parseInt(e.target.value))} + min="10" max="60" step="5" + /> +
+
+ + handleChange('ASTEROID_SIZE_SMALL', parseInt(e.target.value))} + min="5" max="40" step="2" + /> +
+
+ + handleChange('ASTEROID_SPEED_LARGE', parseFloat(e.target.value))} + min="0.1" max="3" step="0.1" + /> +
+
+ + handleChange('ASTEROID_SPEED_MEDIUM', parseFloat(e.target.value))} + min="0.1" max="4" step="0.1" + /> +
+
+ + handleChange('ASTEROID_SPEED_SMALL', parseFloat(e.target.value))} + min="0.1" max="5" step="0.1" + /> +
+
+ +
+

🎯 Scoring

+
+ + handleChange('SCORE_LARGE', parseInt(e.target.value))} + min="1" max="100" step="5" + /> +
+
+ + handleChange('SCORE_MEDIUM', parseInt(e.target.value))} + min="1" max="200" step="10" + /> +
+
+ + handleChange('SCORE_SMALL', parseInt(e.target.value))} + min="1" max="300" step="10" + /> +
+
+ +
+ ✅ Changes apply immediately! Click Reset to restart training with new config. +
+
+ )} +
+ ); +} diff --git a/src/apps/AsteroidsAI/DenseNetwork.ts b/src/apps/AsteroidsAI/DenseNetwork.ts new file mode 100644 index 0000000..6c92c78 --- /dev/null +++ b/src/apps/AsteroidsAI/DenseNetwork.ts @@ -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; + } +} diff --git a/src/apps/AsteroidsAI/GeneticAlgo.ts b/src/apps/AsteroidsAI/GeneticAlgo.ts new file mode 100644 index 0000000..3db750e --- /dev/null +++ b/src/apps/AsteroidsAI/GeneticAlgo.ts @@ -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; + } + } + } +} diff --git a/src/apps/AsteroidsAI/config.ts b/src/apps/AsteroidsAI/config.ts new file mode 100644 index 0000000..0dcf50b --- /dev/null +++ b/src/apps/AsteroidsAI/config.ts @@ -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) { + 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() + ]; +} diff --git a/src/apps/AsteroidsAI/debug.test.ts b/src/apps/AsteroidsAI/debug.test.ts new file mode 100644 index 0000000..d5efd7f --- /dev/null +++ b/src/apps/AsteroidsAI/debug.test.ts @@ -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 ==='); diff --git a/src/apps/AsteroidsAI/destruction.test.ts b/src/apps/AsteroidsAI/destruction.test.ts new file mode 100644 index 0000000..a5eae5e --- /dev/null +++ b/src/apps/AsteroidsAI/destruction.test.ts @@ -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!)'); +} diff --git a/src/apps/AsteroidsAI/fitnessConfig.ts b/src/apps/AsteroidsAI/fitnessConfig.ts new file mode 100644 index 0000000..4198f03 --- /dev/null +++ b/src/apps/AsteroidsAI/fitnessConfig.ts @@ -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); +} + diff --git a/src/apps/AsteroidsAI/training.worker.ts b/src/apps/AsteroidsAI/training.worker.ts new file mode 100644 index 0000000..222f8f7 --- /dev/null +++ b/src/apps/AsteroidsAI/training.worker.ts @@ -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); +} diff --git a/src/apps/AsteroidsAI/useEvolutionWorker.ts b/src/apps/AsteroidsAI/useEvolutionWorker.ts new file mode 100644 index 0000000..c594401 --- /dev/null +++ b/src/apps/AsteroidsAI/useEvolutionWorker.ts @@ -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({ generation: 0, maxFitness: 0, avgFitness: 0 }); + const [fitnessHistory, setFitnessHistory] = useState([]); + const [bestGenome, setBestGenome] = useState(null); + + const workerRef = useRef(null); + + useEffect(() => { + const worker = new Worker(new URL('./training.worker.ts', import.meta.url), { type: 'module' }); + workerRef.current = worker; + + worker.onmessage = (e: MessageEvent) => { + 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 + }; +} diff --git a/src/apps/BridgeBuilder/.reload b/src/apps/BridgeBuilder/.reload new file mode 100644 index 0000000..0ce7184 --- /dev/null +++ b/src/apps/BridgeBuilder/.reload @@ -0,0 +1,2 @@ +// Force reload marker - change me to trigger HMR +export const RELOAD_MARKER = 2; diff --git a/src/apps/BridgeBuilder/BridgeBuilder.css b/src/apps/BridgeBuilder/BridgeBuilder.css new file mode 100644 index 0000000..b09ab09 --- /dev/null +++ b/src/apps/BridgeBuilder/BridgeBuilder.css @@ -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; +} \ No newline at end of file diff --git a/src/apps/BridgeBuilder/BridgeBuilderApp.tsx b/src/apps/BridgeBuilder/BridgeBuilderApp.tsx new file mode 100644 index 0000000..edffedb --- /dev/null +++ b/src/apps/BridgeBuilder/BridgeBuilderApp.tsx @@ -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(null); + const gameRef = useRef(null); + const sceneRef = useRef(null); + + const [bridgeConfig, setBridgeConfig] = useState(DEFAULT_BRIDGE_CONFIG); + const [simConfig, setSimConfig] = useState(DEFAULT_SIM_CONFIG); + const [gaConfig, setGaConfig] = useState(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 ( +
+
+

🌉 Bridge Builder

+

+ Watch evolution discover structural engineering solutions +

+
+ +
+
+ + +
+
+ ); +} diff --git a/src/apps/BridgeBuilder/BridgeScene.ts b/src/apps/BridgeBuilder/BridgeScene.ts new file mode 100644 index 0000000..aac6546 --- /dev/null +++ b/src/apps/BridgeBuilder/BridgeScene.ts @@ -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'}`, + ]); + } +} diff --git a/src/apps/BridgeBuilder/BridgeSimulation.ts b/src/apps/BridgeBuilder/BridgeSimulation.ts new file mode 100644 index 0000000..ee76c3b --- /dev/null +++ b/src/apps/BridgeBuilder/BridgeSimulation.ts @@ -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(); + + 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(); + 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, start: number, target: number): boolean { + const visited = new Set(); + 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 + }; + } +} diff --git a/src/apps/BridgeBuilder/FitnessGraph.tsx b/src/apps/BridgeBuilder/FitnessGraph.tsx new file mode 100644 index 0000000..e5e43ac --- /dev/null +++ b/src/apps/BridgeBuilder/FitnessGraph.tsx @@ -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(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 ( + + ); +} diff --git a/src/apps/BridgeBuilder/GeneticAlgo.ts b/src/apps/BridgeBuilder/GeneticAlgo.ts new file mode 100644 index 0000000..70c1f33 --- /dev/null +++ b/src/apps/BridgeBuilder/GeneticAlgo.ts @@ -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(); + 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, start: number): Set { + const visited = new Set(); + 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 })), + }; + } +} diff --git a/src/apps/BridgeBuilder/e2e.test.ts b/src/apps/BridgeBuilder/e2e.test.ts new file mode 100644 index 0000000..821e769 --- /dev/null +++ b/src/apps/BridgeBuilder/e2e.test.ts @@ -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); + }); +}); diff --git a/src/apps/BridgeBuilder/manual_test.ts b/src/apps/BridgeBuilder/manual_test.ts new file mode 100644 index 0000000..b37beae --- /dev/null +++ b/src/apps/BridgeBuilder/manual_test.ts @@ -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}`); diff --git a/src/apps/BridgeBuilder/training.worker.ts b/src/apps/BridgeBuilder/training.worker.ts new file mode 100644 index 0000000..21e4fc0 --- /dev/null +++ b/src/apps/BridgeBuilder/training.worker.ts @@ -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); +} diff --git a/src/apps/BridgeBuilder/types.ts b/src/apps/BridgeBuilder/types.ts new file mode 100644 index 0000000..21b6945 --- /dev/null +++ b/src/apps/BridgeBuilder/types.ts @@ -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, +}; diff --git a/src/apps/BridgeBuilder/useEvolutionWorker.ts b/src/apps/BridgeBuilder/useEvolutionWorker.ts new file mode 100644 index 0000000..0d8637f --- /dev/null +++ b/src/apps/BridgeBuilder/useEvolutionWorker.ts @@ -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(null); + const [isTraining, setIsTraining] = useState(false); + + // Fitness history for graphing + const [bestFitnessHistory, setBestFitnessHistory] = useState([]); + const [avgFitnessHistory, setAvgFitnessHistory] = useState([]); + + const workerRef = useRef(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, + }; +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 452222e..6c55021 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,7 +1,7 @@ import { NavLink } from 'react-router-dom'; 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 { id: AppId; @@ -47,6 +47,18 @@ export const APPS: AppInfo[] = [ name: 'Self-Driving Car', 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() {