Add neat arena
This commit is contained in:
184
src/lib/neatArena/arenaScene.ts
Normal file
184
src/lib/neatArena/arenaScene.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import Phaser from 'phaser';
|
||||
import type { SimulationState } from './types';
|
||||
import { SIMULATION_CONFIG } from './types';
|
||||
|
||||
/**
|
||||
* Phaser scene for rendering the NEAT Arena.
|
||||
*
|
||||
* This scene is ONLY for visualization - the actual simulation runs separately.
|
||||
* The scene receives simulation state updates and renders them.
|
||||
*/
|
||||
export class ArenaScene extends Phaser.Scene {
|
||||
private simulationState: SimulationState | null = null;
|
||||
private showRays: boolean = true;
|
||||
|
||||
// Graphics objects
|
||||
private wallGraphics!: Phaser.GameObjects.Graphics;
|
||||
private agentGraphics!: Phaser.GameObjects.Graphics;
|
||||
private bulletGraphics!: Phaser.GameObjects.Graphics;
|
||||
private rayGraphics!: Phaser.GameObjects.Graphics;
|
||||
|
||||
constructor() {
|
||||
super({ key: 'ArenaScene' });
|
||||
}
|
||||
|
||||
create() {
|
||||
// Create graphics layers (back to front)
|
||||
this.wallGraphics = this.add.graphics();
|
||||
this.rayGraphics = this.add.graphics();
|
||||
this.bulletGraphics = this.add.graphics();
|
||||
this.agentGraphics = this.add.graphics();
|
||||
|
||||
// Set background
|
||||
this.cameras.main.setBackgroundColor(0x1a1a2e);
|
||||
}
|
||||
|
||||
update() {
|
||||
if (!this.simulationState) return;
|
||||
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the simulation state to render
|
||||
*/
|
||||
public updateSimulation(state: SimulationState) {
|
||||
this.simulationState = state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle ray visualization
|
||||
*/
|
||||
public setShowRays(show: boolean) {
|
||||
this.showRays = show;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the current simulation state
|
||||
*/
|
||||
private render() {
|
||||
if (!this.simulationState) return;
|
||||
|
||||
// Clear graphics
|
||||
this.wallGraphics.clear();
|
||||
this.agentGraphics.clear();
|
||||
this.bulletGraphics.clear();
|
||||
this.rayGraphics.clear();
|
||||
|
||||
// Render walls
|
||||
this.renderWalls();
|
||||
|
||||
// Render rays (if enabled)
|
||||
if (this.showRays) {
|
||||
this.renderRays();
|
||||
}
|
||||
|
||||
// Render bullets
|
||||
this.renderBullets();
|
||||
|
||||
// Render agents
|
||||
this.renderAgents();
|
||||
}
|
||||
|
||||
private renderWalls() {
|
||||
if (!this.simulationState) return;
|
||||
|
||||
const { walls } = this.simulationState.map;
|
||||
|
||||
this.wallGraphics.fillStyle(0x4a5568, 1);
|
||||
this.wallGraphics.lineStyle(2, 0x64748b, 1);
|
||||
|
||||
for (const wall of walls) {
|
||||
const { minX, minY, maxX, maxY } = wall.rect;
|
||||
this.wallGraphics.fillRect(minX, minY, maxX - minX, maxY - minY);
|
||||
this.wallGraphics.strokeRect(minX, minY, maxX - minX, maxY - minY);
|
||||
}
|
||||
}
|
||||
|
||||
private renderAgents() {
|
||||
if (!this.simulationState) return;
|
||||
|
||||
const agents = this.simulationState.agents;
|
||||
const colors = [0x667eea, 0xf093fb]; // Purple and pink
|
||||
|
||||
for (let i = 0; i < agents.length; i++) {
|
||||
const agent = agents[i];
|
||||
const color = colors[i];
|
||||
|
||||
// Agent body (circle)
|
||||
if (agent.invulnTicks > 0) {
|
||||
// Flash when invulnerable
|
||||
const alpha = agent.invulnTicks % 4 < 2 ? 0.5 : 1;
|
||||
this.agentGraphics.fillStyle(color, alpha);
|
||||
} else {
|
||||
this.agentGraphics.fillStyle(color, 1);
|
||||
}
|
||||
|
||||
this.agentGraphics.fillCircle(agent.position.x, agent.position.y, agent.radius);
|
||||
|
||||
// Border
|
||||
this.agentGraphics.lineStyle(2, 0xffffff, 0.8);
|
||||
this.agentGraphics.strokeCircle(agent.position.x, agent.position.y, agent.radius);
|
||||
|
||||
// Aim direction indicator
|
||||
const aimLength = 20;
|
||||
const aimEndX = agent.position.x + Math.cos(agent.aimAngle) * aimLength;
|
||||
const aimEndY = agent.position.y + Math.sin(agent.aimAngle) * aimLength;
|
||||
|
||||
this.agentGraphics.lineStyle(3, 0xffffff, 1);
|
||||
this.agentGraphics.lineBetween(agent.position.x, agent.position.y, aimEndX, aimEndY);
|
||||
}
|
||||
}
|
||||
|
||||
private renderBullets() {
|
||||
if (!this.simulationState) return;
|
||||
|
||||
this.bulletGraphics.fillStyle(0xfbbf24, 1); // Yellow
|
||||
this.bulletGraphics.lineStyle(1, 0xffffff, 0.8);
|
||||
|
||||
for (const bullet of this.simulationState.bullets) {
|
||||
this.bulletGraphics.fillCircle(bullet.position.x, bullet.position.y, 3);
|
||||
this.bulletGraphics.strokeCircle(bullet.position.x, bullet.position.y, 3);
|
||||
}
|
||||
}
|
||||
|
||||
private renderRays() {
|
||||
if (!this.simulationState) return;
|
||||
|
||||
// TODO: This will be implemented when we integrate sensor visualization
|
||||
// For now, rays will be rendered when we have a specific agent's observation to display
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and initialize a Phaser game instance for the arena
|
||||
*/
|
||||
export function createArenaViewer(parentElement: HTMLElement): Phaser.Game {
|
||||
const config: Phaser.Types.Core.GameConfig = {
|
||||
type: Phaser.AUTO,
|
||||
width: SIMULATION_CONFIG.WORLD_SIZE,
|
||||
height: SIMULATION_CONFIG.WORLD_SIZE,
|
||||
parent: parentElement,
|
||||
backgroundColor: '#1a1a2e',
|
||||
scene: ArenaScene,
|
||||
physics: {
|
||||
default: 'arcade',
|
||||
arcade: {
|
||||
debug: false,
|
||||
},
|
||||
},
|
||||
scale: {
|
||||
mode: Phaser.Scale.FIT,
|
||||
autoCenter: Phaser.Scale.CENTER_BOTH,
|
||||
},
|
||||
};
|
||||
|
||||
return new Phaser.Game(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the scene instance from a Phaser game
|
||||
*/
|
||||
export function getArenaScene(game: Phaser.Game): ArenaScene {
|
||||
return game.scene.getScene('ArenaScene') as ArenaScene;
|
||||
}
|
||||
60
src/lib/neatArena/baselineBots.ts
Normal file
60
src/lib/neatArena/baselineBots.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { AgentAction } from './types';
|
||||
import { SeededRandom } from './utils';
|
||||
|
||||
/**
|
||||
* Baseline scripted bots for testing and benchmarking.
|
||||
*
|
||||
* These provide simple strategies that can be used to:
|
||||
* - Test the simulation mechanics
|
||||
* - Provide initial training opponents
|
||||
* - Benchmark evolved agents
|
||||
*/
|
||||
|
||||
/**
|
||||
* Random bot - takes random actions
|
||||
*/
|
||||
export function randomBotAction(rng: SeededRandom): AgentAction {
|
||||
return {
|
||||
moveX: rng.nextFloat(-1, 1),
|
||||
moveY: rng.nextFloat(-1, 1),
|
||||
turn: rng.nextFloat(-1, 1),
|
||||
shoot: rng.next(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Idle bot - does nothing
|
||||
*/
|
||||
export function idleBotAction(): AgentAction {
|
||||
return {
|
||||
moveX: 0,
|
||||
moveY: 0,
|
||||
turn: 0,
|
||||
shoot: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Spinner bot - spins in place and shoots
|
||||
*/
|
||||
export function spinnerBotAction(): AgentAction {
|
||||
return {
|
||||
moveX: 0,
|
||||
moveY: 0,
|
||||
turn: 1,
|
||||
shoot: 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Circle strafe bot - moves in circles and shoots
|
||||
*/
|
||||
export function circleStrafeBotAction(tick: number): AgentAction {
|
||||
const angle = (tick / 20) * Math.PI * 2;
|
||||
return {
|
||||
moveX: Math.cos(angle),
|
||||
moveY: Math.sin(angle),
|
||||
turn: 0.3,
|
||||
shoot: tick % 15 === 0 ? 1 : 0,
|
||||
};
|
||||
}
|
||||
76
src/lib/neatArena/crossover.ts
Normal file
76
src/lib/neatArena/crossover.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { Genome, InnovationTracker } from './genome';
|
||||
import { cloneGenome } from './genome';
|
||||
|
||||
/**
|
||||
* NEAT Crossover
|
||||
*
|
||||
* Produces offspring by crossing over two parent genomes.
|
||||
* Follows the NEAT crossover rules:
|
||||
* - Matching genes are randomly inherited
|
||||
* - Disjoint/excess genes are inherited from the fitter parent
|
||||
* - Disabled genes have a chance to stay disabled
|
||||
*/
|
||||
|
||||
const DISABLED_GENE_INHERITANCE_RATE = 0.75;
|
||||
|
||||
/**
|
||||
* Perform crossover between two genomes
|
||||
* @param parent1 First parent (should be fitter or equal)
|
||||
* @param parent2 Second parent
|
||||
* @param innovationTracker Not used in crossover, but kept for consistency
|
||||
* @returns Offspring genome
|
||||
*/
|
||||
export function crossover(
|
||||
parent1: Genome,
|
||||
parent2: Genome,
|
||||
innovationTracker?: InnovationTracker
|
||||
): Genome {
|
||||
// Ensure parent1 is fitter (or equal)
|
||||
if (parent2.fitness > parent1.fitness) {
|
||||
[parent1, parent2] = [parent2, parent1];
|
||||
}
|
||||
|
||||
const offspring = cloneGenome(parent1);
|
||||
offspring.connections = [];
|
||||
offspring.fitness = 0;
|
||||
|
||||
// Build innovation maps
|
||||
const p1Connections = new Map(
|
||||
parent1.connections.map(c => [c.innovation, c])
|
||||
);
|
||||
const p2Connections = new Map(
|
||||
parent2.connections.map(c => [c.innovation, c])
|
||||
);
|
||||
|
||||
// Get all innovation numbers
|
||||
const allInnovations = new Set([
|
||||
...p1Connections.keys(),
|
||||
...p2Connections.keys(),
|
||||
]);
|
||||
|
||||
for (const innovation of allInnovations) {
|
||||
const conn1 = p1Connections.get(innovation);
|
||||
const conn2 = p2Connections.get(innovation);
|
||||
|
||||
if (conn1 && conn2) {
|
||||
// Matching gene - randomly choose from either parent
|
||||
const chosen = Math.random() < 0.5 ? conn1 : conn2;
|
||||
const newConn = { ...chosen };
|
||||
|
||||
// Handle disabled gene inheritance
|
||||
if (!conn1.enabled || !conn2.enabled) {
|
||||
if (Math.random() < DISABLED_GENE_INHERITANCE_RATE) {
|
||||
newConn.enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
offspring.connections.push(newConn);
|
||||
} else if (conn1) {
|
||||
// Disjoint/excess gene from parent1 (fitter)
|
||||
offspring.connections.push({ ...conn1 });
|
||||
}
|
||||
// Genes only in parent2 are not inherited (parent1 is fitter)
|
||||
}
|
||||
|
||||
return offspring;
|
||||
}
|
||||
154
src/lib/neatArena/evolution.ts
Normal file
154
src/lib/neatArena/evolution.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { InnovationTracker, type Genome } from './genome';
|
||||
import type { Species } from './speciation';
|
||||
import type { ReproductionConfig } from './reproduction';
|
||||
import { createMinimalGenome } from './genome';
|
||||
import {
|
||||
speciate,
|
||||
adjustCompatibilityThreshold,
|
||||
applyFitnessSharing,
|
||||
DEFAULT_COMPATIBILITY_CONFIG,
|
||||
type CompatibilityConfig,
|
||||
} from './speciation';
|
||||
import { reproduce, DEFAULT_REPRODUCTION_CONFIG } from './reproduction';
|
||||
|
||||
/**
|
||||
* NEAT Evolution Engine
|
||||
*
|
||||
* Coordinates the entire evolution process:
|
||||
* - Population management
|
||||
* - Speciation
|
||||
* - Fitness evaluation
|
||||
* - Reproduction
|
||||
*/
|
||||
|
||||
export interface EvolutionConfig {
|
||||
populationSize: number;
|
||||
inputCount: number;
|
||||
outputCount: number;
|
||||
compatibilityConfig: CompatibilityConfig;
|
||||
reproductionConfig: ReproductionConfig;
|
||||
}
|
||||
|
||||
export const DEFAULT_EVOLUTION_CONFIG: EvolutionConfig = {
|
||||
populationSize: 40,
|
||||
inputCount: 53, // Ray sensors + extra inputs
|
||||
outputCount: 5, // moveX, moveY, turn, shoot, reserved
|
||||
compatibilityConfig: DEFAULT_COMPATIBILITY_CONFIG,
|
||||
reproductionConfig: DEFAULT_REPRODUCTION_CONFIG,
|
||||
};
|
||||
|
||||
export interface Population {
|
||||
genomes: Genome[];
|
||||
species: Species[];
|
||||
generation: number;
|
||||
compatibilityThreshold: number;
|
||||
innovationTracker: InnovationTracker;
|
||||
bestGenomeEver: Genome | null;
|
||||
bestFitnessEver: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create initial population
|
||||
*/
|
||||
export function createPopulation(config: EvolutionConfig): Population {
|
||||
const innovationTracker = new InnovationTracker();
|
||||
const genomes: Genome[] = [];
|
||||
|
||||
for (let i = 0; i < config.populationSize; i++) {
|
||||
genomes.push(createMinimalGenome(
|
||||
config.inputCount,
|
||||
config.outputCount,
|
||||
innovationTracker
|
||||
));
|
||||
}
|
||||
|
||||
return {
|
||||
genomes,
|
||||
species: [],
|
||||
generation: 0,
|
||||
compatibilityThreshold: 1.5, // Balanced to target 6-10 species
|
||||
innovationTracker,
|
||||
bestGenomeEver: null,
|
||||
bestFitnessEver: -Infinity,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Evolve the population by one generation
|
||||
*
|
||||
* Note: This assumes genomes have already been evaluated and have fitness values.
|
||||
*/
|
||||
export function evolveGeneration(population: Population, config: EvolutionConfig): Population {
|
||||
// 1. Speciate
|
||||
const species = speciate(
|
||||
population.genomes,
|
||||
population.species,
|
||||
population.compatibilityThreshold,
|
||||
config.compatibilityConfig
|
||||
);
|
||||
|
||||
// 2. Apply fitness sharing
|
||||
applyFitnessSharing(species);
|
||||
|
||||
// 3. Remove stagnant species (optional for now)
|
||||
// TODO: Implement staleness checking and removal
|
||||
|
||||
// 4. Track best genome
|
||||
let bestGenome = population.bestGenomeEver;
|
||||
let bestFitness = population.bestFitnessEver;
|
||||
|
||||
for (const genome of population.genomes) {
|
||||
if (genome.fitness > bestFitness) {
|
||||
bestFitness = genome.fitness;
|
||||
bestGenome = genome;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Reproduce
|
||||
const newGenomes = reproduce(
|
||||
species,
|
||||
config.populationSize,
|
||||
population.innovationTracker,
|
||||
config.reproductionConfig
|
||||
);
|
||||
|
||||
// 6. Adjust compatibility threshold
|
||||
const newThreshold = adjustCompatibilityThreshold(
|
||||
population.compatibilityThreshold,
|
||||
species.length
|
||||
);
|
||||
|
||||
return {
|
||||
genomes: newGenomes,
|
||||
species,
|
||||
generation: population.generation + 1,
|
||||
compatibilityThreshold: newThreshold,
|
||||
innovationTracker: population.innovationTracker,
|
||||
bestGenomeEver: bestGenome,
|
||||
bestFitnessEver: bestFitness,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics for the current population
|
||||
*/
|
||||
export function getPopulationStats(population: Population) {
|
||||
const fitnesses = population.genomes.map(g => g.fitness);
|
||||
const avgFitness = fitnesses.reduce((a, b) => a + b, 0) / fitnesses.length;
|
||||
const maxFitness = Math.max(...fitnesses);
|
||||
const minFitness = Math.min(...fitnesses);
|
||||
|
||||
// When population comes from worker, innovationTracker is a plain object
|
||||
// Access the private property directly instead of calling method
|
||||
const totalInnovations = (population.innovationTracker as any).currentInnovation || 0;
|
||||
|
||||
return {
|
||||
generation: population.generation,
|
||||
speciesCount: population.species.length,
|
||||
avgFitness,
|
||||
maxFitness,
|
||||
minFitness,
|
||||
bestFitnessEver: population.bestFitnessEver,
|
||||
totalInnovations,
|
||||
};
|
||||
}
|
||||
120
src/lib/neatArena/exportImport.ts
Normal file
120
src/lib/neatArena/exportImport.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import type { Genome } from './genome';
|
||||
import type { EvolutionConfig } from './evolution';
|
||||
|
||||
/**
|
||||
* Export/Import system for trained genomes.
|
||||
*
|
||||
* Allows saving champion genomes as JSON files and loading them back
|
||||
* for exhibition matches or continued training.
|
||||
*/
|
||||
|
||||
export interface ExportedGenome {
|
||||
version: string;
|
||||
timestamp: number;
|
||||
config: {
|
||||
inputCount: number;
|
||||
outputCount: number;
|
||||
};
|
||||
genome: Genome;
|
||||
metadata?: {
|
||||
generation?: number;
|
||||
fitness?: number;
|
||||
speciesCount?: number;
|
||||
};
|
||||
}
|
||||
|
||||
const EXPORT_VERSION = '1.0.0';
|
||||
|
||||
/**
|
||||
* Export a genome to a downloadable JSON format
|
||||
*/
|
||||
export function exportGenome(
|
||||
genome: Genome,
|
||||
config: EvolutionConfig,
|
||||
metadata?: ExportedGenome['metadata']
|
||||
): ExportedGenome {
|
||||
return {
|
||||
version: EXPORT_VERSION,
|
||||
timestamp: Date.now(),
|
||||
config: {
|
||||
inputCount: config.inputCount,
|
||||
outputCount: config.outputCount,
|
||||
},
|
||||
genome: {
|
||||
nodes: genome.nodes,
|
||||
connections: genome.connections,
|
||||
fitness: genome.fitness,
|
||||
},
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a genome from JSON
|
||||
*/
|
||||
export function importGenome(exported: ExportedGenome): {
|
||||
genome: Genome;
|
||||
config: { inputCount: number; outputCount: number };
|
||||
} {
|
||||
// Version check
|
||||
if (exported.version !== EXPORT_VERSION) {
|
||||
console.warn(`Imported genome version ${exported.version} may be incompatible with current version ${EXPORT_VERSION}`);
|
||||
}
|
||||
|
||||
return {
|
||||
genome: exported.genome,
|
||||
config: exported.config,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Download genome as JSON file
|
||||
*/
|
||||
export function downloadGenomeAsFile(exported: ExportedGenome, filename?: string): void {
|
||||
const json = JSON.stringify(exported, null, 2);
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename || `neat-champion-${Date.now()}.json`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload and parse genome from file
|
||||
*/
|
||||
export function uploadGenomeFromFile(): Promise<ExportedGenome> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'application/json,.json';
|
||||
|
||||
input.onchange = (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!file) {
|
||||
reject(new Error('No file selected'));
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
try {
|
||||
const json = event.target?.result as string;
|
||||
const exported = JSON.parse(json) as ExportedGenome;
|
||||
resolve(exported);
|
||||
} catch (err) {
|
||||
reject(new Error('Failed to parse genome file'));
|
||||
}
|
||||
};
|
||||
reader.onerror = () => reject(new Error('Failed to read file'));
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
input.click();
|
||||
});
|
||||
}
|
||||
86
src/lib/neatArena/fitness.ts
Normal file
86
src/lib/neatArena/fitness.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { SimulationState } from './types';
|
||||
import { hasLineOfSight } from './sensors';
|
||||
|
||||
/**
|
||||
* Fitness calculation for NEAT Arena.
|
||||
*
|
||||
* Fitness rewards:
|
||||
* - +10 per hit on opponent
|
||||
* - -10 per being hit
|
||||
* - -0.002 per tick (time penalty to encourage aggression)
|
||||
* - -0.2 per shot fired (ammo management)
|
||||
* - +0.01 per tick when aiming well at visible opponent
|
||||
*/
|
||||
|
||||
export interface FitnessTracker {
|
||||
agentId: number;
|
||||
fitness: number;
|
||||
|
||||
// For incremental calculation
|
||||
lastKills: number;
|
||||
lastHits: number;
|
||||
shotsFired: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new fitness tracker
|
||||
*/
|
||||
export function createFitnessTracker(agentId: number): FitnessTracker {
|
||||
return {
|
||||
agentId,
|
||||
fitness: 0,
|
||||
lastKills: 0,
|
||||
lastHits: 0,
|
||||
shotsFired: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update fitness based on current simulation state
|
||||
*/
|
||||
export function updateFitness(tracker: FitnessTracker, state: SimulationState): FitnessTracker {
|
||||
const agent = state.agents.find(a => a.id === tracker.agentId)!;
|
||||
const opponent = state.agents.find(a => a.id !== tracker.agentId)!;
|
||||
|
||||
const newTracker = { ...tracker };
|
||||
|
||||
// Reward for new kills
|
||||
const newKills = agent.kills - tracker.lastKills;
|
||||
newTracker.fitness += newKills * 10;
|
||||
newTracker.lastKills = agent.kills;
|
||||
|
||||
// Penalty for being hit
|
||||
const newHits = agent.hits - tracker.lastHits;
|
||||
newTracker.fitness -= newHits * 10;
|
||||
newTracker.lastHits = agent.hits;
|
||||
|
||||
// Time penalty (encourages finishing quickly)
|
||||
newTracker.fitness -= 0.002;
|
||||
|
||||
// Check if agent fired this tick (cooldown just set)
|
||||
if (agent.fireCooldown === 10) {
|
||||
newTracker.shotsFired++;
|
||||
newTracker.fitness -= 0.2;
|
||||
}
|
||||
|
||||
// Reward for aiming at visible opponent
|
||||
if (hasLineOfSight(agent, opponent, state.map.walls)) {
|
||||
const dx = opponent.position.x - agent.position.x;
|
||||
const dy = opponent.position.y - agent.position.y;
|
||||
const angleToOpponent = Math.atan2(dy, dx);
|
||||
|
||||
// Normalize angle difference
|
||||
let angleDiff = angleToOpponent - agent.aimAngle;
|
||||
while (angleDiff > Math.PI) angleDiff -= 2 * Math.PI;
|
||||
while (angleDiff < -Math.PI) angleDiff += 2 * Math.PI;
|
||||
|
||||
const cosAngleDiff = Math.cos(angleDiff);
|
||||
|
||||
// Reward if aiming close (cos > 0.95 ≈ within ~18°)
|
||||
if (cosAngleDiff > 0.95) {
|
||||
newTracker.fitness += 0.01;
|
||||
}
|
||||
}
|
||||
|
||||
return newTracker;
|
||||
}
|
||||
214
src/lib/neatArena/genome.ts
Normal file
214
src/lib/neatArena/genome.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* NEAT Genome Implementation
|
||||
*
|
||||
* Represents a neural network genome with node genes and connection genes.
|
||||
* Implements the core NEAT genome structure as described in the original paper.
|
||||
*/
|
||||
|
||||
export type NodeType = 'input' | 'hidden' | 'output';
|
||||
export type ActivationFunction = 'tanh' | 'sigmoid' | 'relu' | 'linear';
|
||||
|
||||
/**
|
||||
* Node gene - represents a neuron
|
||||
*/
|
||||
export interface NodeGene {
|
||||
id: number;
|
||||
type: NodeType;
|
||||
activation: ActivationFunction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection gene - represents a synapse
|
||||
*/
|
||||
export interface ConnectionGene {
|
||||
innovation: number;
|
||||
from: number;
|
||||
to: number;
|
||||
weight: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete genome
|
||||
*/
|
||||
export interface Genome {
|
||||
nodes: NodeGene[];
|
||||
connections: ConnectionGene[];
|
||||
fitness: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Global innovation tracker for historical markings
|
||||
*/
|
||||
export class InnovationTracker {
|
||||
private currentInnovation: number = 0;
|
||||
private innovationHistory: Map<string, number> = new Map();
|
||||
|
||||
/**
|
||||
* Get or create innovation number for a connection
|
||||
*/
|
||||
getInnovation(from: number, to: number): number {
|
||||
const key = `${from}->${to}`;
|
||||
|
||||
if (this.innovationHistory.has(key)) {
|
||||
return this.innovationHistory.get(key)!;
|
||||
}
|
||||
|
||||
const innovation = this.currentInnovation++;
|
||||
this.innovationHistory.set(key, innovation);
|
||||
return innovation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset innovation tracking (useful for new experiments)
|
||||
*/
|
||||
reset(): void {
|
||||
this.currentInnovation = 0;
|
||||
this.innovationHistory.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current innovation count
|
||||
*/
|
||||
getCurrentInnovation(): number {
|
||||
return this.currentInnovation;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a minimal genome with only input and output nodes, fully connected
|
||||
*/
|
||||
export function createMinimalGenome(
|
||||
inputCount: number,
|
||||
outputCount: number,
|
||||
innovationTracker: InnovationTracker
|
||||
): Genome {
|
||||
const nodes: NodeGene[] = [];
|
||||
const connections: ConnectionGene[] = [];
|
||||
|
||||
// Create input nodes (IDs 0 to inputCount-1)
|
||||
for (let i = 0; i < inputCount; i++) {
|
||||
nodes.push({
|
||||
id: i,
|
||||
type: 'input',
|
||||
activation: 'linear',
|
||||
});
|
||||
}
|
||||
|
||||
// Create output nodes (IDs starting from inputCount)
|
||||
for (let i = 0; i < outputCount; i++) {
|
||||
nodes.push({
|
||||
id: inputCount + i,
|
||||
type: 'output',
|
||||
activation: 'tanh',
|
||||
});
|
||||
}
|
||||
|
||||
// Create fully connected minimal genome
|
||||
for (let i = 0; i < inputCount; i++) {
|
||||
const inputNode = i; // Assuming inputNode refers to the ID
|
||||
|
||||
for (let o = 0; o < outputCount; o++) {
|
||||
const outputNode = inputCount + o; // Assuming outputNode refers to the ID
|
||||
const innovation = innovationTracker.getInnovation(inputNode, outputNode);
|
||||
|
||||
connections.push({
|
||||
innovation,
|
||||
from: inputNode,
|
||||
to: outputNode,
|
||||
weight: (Math.random() * 4) - 2, // Random weight in [-2, 2] for initial diversity
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodes,
|
||||
connections,
|
||||
fitness: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone a genome (deep copy)
|
||||
*/
|
||||
export function cloneGenome(genome: Genome): Genome {
|
||||
return {
|
||||
nodes: genome.nodes.map(n => ({ ...n })),
|
||||
connections: genome.connections.map(c => ({ ...c })),
|
||||
fitness: genome.fitness,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get next available node ID
|
||||
*/
|
||||
export function getNextNodeId(genome: Genome): number {
|
||||
return Math.max(...genome.nodes.map(n => n.id)) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a connection already exists
|
||||
*/
|
||||
export function connectionExists(genome: Genome, from: number, to: number): boolean {
|
||||
return genome.connections.some(c => c.from === from && c.to === to);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if adding a connection would create a cycle (for feedforward networks)
|
||||
*/
|
||||
export function wouldCreateCycle(genome: Genome, from: number, to: number): boolean {
|
||||
// Build adjacency list
|
||||
const adj = new Map<number, number[]>();
|
||||
for (const node of genome.nodes) {
|
||||
adj.set(node.id, []);
|
||||
}
|
||||
|
||||
for (const conn of genome.connections) {
|
||||
if (!conn.enabled) continue;
|
||||
if (!adj.has(conn.from)) adj.set(conn.from, []);
|
||||
adj.get(conn.from)!.push(conn.to);
|
||||
}
|
||||
|
||||
// Add the proposed connection
|
||||
if (!adj.has(from)) adj.set(from, []);
|
||||
adj.get(from)!.push(to);
|
||||
|
||||
// DFS to detect cycle
|
||||
const visited = new Set<number>();
|
||||
const recStack = new Set<number>();
|
||||
|
||||
const hasCycle = (nodeId: number): boolean => {
|
||||
visited.add(nodeId);
|
||||
recStack.add(nodeId);
|
||||
|
||||
const neighbors = adj.get(nodeId) || [];
|
||||
for (const neighbor of neighbors) {
|
||||
if (!visited.has(neighbor)) {
|
||||
if (hasCycle(neighbor)) return true;
|
||||
} else if (recStack.has(neighbor)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
recStack.delete(nodeId);
|
||||
return false;
|
||||
};
|
||||
|
||||
// Check from the 'from' node
|
||||
return hasCycle(from);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize genome to JSON
|
||||
*/
|
||||
export function serializeGenome(genome: Genome): string {
|
||||
return JSON.stringify(genome, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize genome from JSON
|
||||
*/
|
||||
export function deserializeGenome(json: string): Genome {
|
||||
return JSON.parse(json);
|
||||
}
|
||||
123
src/lib/neatArena/mapGenerator.ts
Normal file
123
src/lib/neatArena/mapGenerator.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import type { ArenaMap, Wall, SpawnPoint, AABB, Vec2 } from './types';
|
||||
import { SIMULATION_CONFIG } from './types';
|
||||
import { SeededRandom } from './utils';
|
||||
|
||||
/**
|
||||
* Generates a symmetric arena map with procedurally placed walls.
|
||||
*
|
||||
* The map is generated by creating walls on the left half, then mirroring them
|
||||
* to the right half for perfect symmetry.
|
||||
*
|
||||
* Spawn points are placed symmetrically as well.
|
||||
*/
|
||||
export function generateArenaMap(seed: number): ArenaMap {
|
||||
const rng = new SeededRandom(seed);
|
||||
const { WORLD_SIZE } = SIMULATION_CONFIG;
|
||||
|
||||
const walls: Wall[] = [];
|
||||
const spawnPoints: SpawnPoint[] = [];
|
||||
|
||||
// Add boundary walls
|
||||
const wallThickness = 16;
|
||||
walls.push(
|
||||
// Top
|
||||
{ rect: { minX: 0, minY: 0, maxX: WORLD_SIZE, maxY: wallThickness } },
|
||||
// Bottom
|
||||
{ rect: { minX: 0, minY: WORLD_SIZE - wallThickness, maxX: WORLD_SIZE, maxY: WORLD_SIZE } },
|
||||
// Left
|
||||
{ rect: { minX: 0, minY: 0, maxX: wallThickness, maxY: WORLD_SIZE } },
|
||||
// Right
|
||||
{ rect: { minX: WORLD_SIZE - wallThickness, minY: 0, maxX: WORLD_SIZE, maxY: WORLD_SIZE } }
|
||||
);
|
||||
|
||||
// Generate interior walls on left half, then mirror
|
||||
const numInteriorWalls = rng.nextInt(3, 6);
|
||||
const leftHalfWalls: AABB[] = [];
|
||||
|
||||
for (let i = 0; i < numInteriorWalls; i++) {
|
||||
const width = rng.nextFloat(30, 80);
|
||||
const height = rng.nextFloat(30, 80);
|
||||
|
||||
// Keep walls in left half (with margin)
|
||||
const minX = rng.nextFloat(wallThickness + 20, WORLD_SIZE / 2 - width - 20);
|
||||
const minY = rng.nextFloat(wallThickness + 20, WORLD_SIZE - height - wallThickness - 20);
|
||||
|
||||
const wall: AABB = {
|
||||
minX,
|
||||
minY,
|
||||
maxX: minX + width,
|
||||
maxY: minY + height,
|
||||
};
|
||||
|
||||
leftHalfWalls.push(wall);
|
||||
walls.push({ rect: wall });
|
||||
}
|
||||
|
||||
// Mirror walls to right half
|
||||
for (const leftWall of leftHalfWalls) {
|
||||
const centerX = WORLD_SIZE / 2;
|
||||
const distFromCenter = centerX - ((leftWall.minX + leftWall.maxX) / 2);
|
||||
const mirroredCenterX = centerX + distFromCenter;
|
||||
const wallWidth = leftWall.maxX - leftWall.minX;
|
||||
|
||||
const mirroredWall: AABB = {
|
||||
minX: mirroredCenterX - wallWidth / 2,
|
||||
maxX: mirroredCenterX + wallWidth / 2,
|
||||
minY: leftWall.minY,
|
||||
maxY: leftWall.maxY,
|
||||
};
|
||||
|
||||
walls.push({ rect: mirroredWall });
|
||||
}
|
||||
|
||||
// Generate 5 symmetric spawn point pairs
|
||||
// Spawn points should be clear of walls
|
||||
for (let pairId = 0; pairId < 5; pairId++) {
|
||||
let leftSpawn: Vec2;
|
||||
let attempts = 0;
|
||||
|
||||
// Find a valid spawn point on the left
|
||||
do {
|
||||
leftSpawn = {
|
||||
x: rng.nextFloat(wallThickness + 40, WORLD_SIZE / 2 - 40),
|
||||
y: rng.nextFloat(wallThickness + 40, WORLD_SIZE - wallThickness - 40),
|
||||
};
|
||||
attempts++;
|
||||
} while (isPositionInWall(leftSpawn, walls) && attempts < 50);
|
||||
|
||||
// Mirror to right
|
||||
const rightSpawn: Vec2 = {
|
||||
x: WORLD_SIZE - leftSpawn.x,
|
||||
y: leftSpawn.y,
|
||||
};
|
||||
|
||||
spawnPoints.push(
|
||||
{ position: leftSpawn, pairId, side: 0 },
|
||||
{ position: rightSpawn, pairId, side: 1 }
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
walls,
|
||||
spawnPoints,
|
||||
seed,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a position overlaps with any wall
|
||||
*/
|
||||
function isPositionInWall(pos: Vec2, walls: Wall[]): boolean {
|
||||
const margin = 20; // give some breathing room
|
||||
for (const wall of walls) {
|
||||
if (
|
||||
pos.x >= wall.rect.minX - margin &&
|
||||
pos.x <= wall.rect.maxX + margin &&
|
||||
pos.y >= wall.rect.minY - margin &&
|
||||
pos.y <= wall.rect.maxY + margin
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
218
src/lib/neatArena/mutations.ts
Normal file
218
src/lib/neatArena/mutations.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import type { Genome, InnovationTracker } from './genome';
|
||||
import {
|
||||
cloneGenome,
|
||||
getNextNodeId,
|
||||
connectionExists,
|
||||
wouldCreateCycle,
|
||||
} from './genome';
|
||||
|
||||
/**
|
||||
* NEAT Mutations
|
||||
*
|
||||
* Implements the core mutation operations:
|
||||
* - Weight perturbation (80%)
|
||||
* - Weight reset (10%)
|
||||
* - Add connection (5%)
|
||||
* - Add node (3%)
|
||||
* - Toggle connection (2%)
|
||||
*/
|
||||
|
||||
export interface MutationRates {
|
||||
mutateWeightsProb: number;
|
||||
resetWeightProb: number;
|
||||
addConnectionProb: number;
|
||||
addNodeProb: number;
|
||||
toggleConnectionProb: number;
|
||||
perturbationPower: number;
|
||||
resetRange: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default mutation probabilities
|
||||
*/
|
||||
export const DEFAULT_MUTATION_RATES: MutationRates = {
|
||||
mutateWeightsProb: 0.50, // Reduced from 0.8 to allow more structural mutations
|
||||
resetWeightProb: 0.05, // Reduced from 0.1
|
||||
addConnectionProb: 0.20, // Increased from 0.05 for more diversity
|
||||
addNodeProb: 0.15, // Increased from 0.03 for more complexity
|
||||
toggleConnectionProb: 0.10, // Increased from 0.02
|
||||
|
||||
// Weight mutation parameters
|
||||
perturbationPower: 0.5, // Increased from 0.1 for stronger weight changes
|
||||
resetRange: 2.0, // Weight reset range
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply mutations to a genome
|
||||
*/
|
||||
export function mutate(genome: Genome, tracker: InnovationTracker, rates = DEFAULT_MUTATION_RATES): void {
|
||||
let addedConnections = 0;
|
||||
let addedNodes = 0;
|
||||
let toggledConnections = 0;
|
||||
|
||||
// Mutate weights
|
||||
if (Math.random() < rates.mutateWeightsProb) {
|
||||
mutateWeights(genome, rates);
|
||||
}
|
||||
|
||||
// Reset a random weight
|
||||
if (Math.random() < rates.resetWeightProb) {
|
||||
resetWeight(genome, rates);
|
||||
}
|
||||
|
||||
// Add connection
|
||||
if (Math.random() < rates.addConnectionProb) {
|
||||
if (addConnection(genome, tracker)) {
|
||||
addedConnections++;
|
||||
}
|
||||
}
|
||||
|
||||
// Add node
|
||||
if (Math.random() < rates.addNodeProb) {
|
||||
if (addNode(genome, tracker)) {
|
||||
addedNodes++;
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle connection
|
||||
if (Math.random() < rates.toggleConnectionProb) {
|
||||
if (toggleConnection(genome)) {
|
||||
toggledConnections++;
|
||||
}
|
||||
}
|
||||
|
||||
// Log structural mutations (only if any happened)
|
||||
if (addedConnections > 0 || addedNodes > 0 || toggledConnections > 0) {
|
||||
console.log(`[Mutation] +${addedConnections} conn, +${addedNodes} nodes, ${toggledConnections} toggled`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perturb weights slightly
|
||||
*/
|
||||
function mutateWeights(genome: Genome, rates: MutationRates): void {
|
||||
for (const conn of genome.connections) {
|
||||
if (Math.random() < 0.9) {
|
||||
// Small perturbation
|
||||
conn.weight += (Math.random() * 2 - 1) * rates.perturbationPower;
|
||||
// Clamp to reasonable range
|
||||
conn.weight = Math.max(-5, Math.min(5, conn.weight));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset a random weight to a new random value
|
||||
*/
|
||||
function resetWeight(genome: Genome, rates: MutationRates): void {
|
||||
if (genome.connections.length === 0) return;
|
||||
|
||||
const conn = genome.connections[Math.floor(Math.random() * genome.connections.length)];
|
||||
conn.weight = (Math.random() * 2 - 1) * rates.resetRange;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new connection between two nodes
|
||||
*/
|
||||
function addConnection(genome: Genome, innovationTracker: InnovationTracker): boolean {
|
||||
const inputNodes = genome.nodes.filter(n => n.type === 'input');
|
||||
const nonInputNodes = genome.nodes.filter(n => n.type !== 'input');
|
||||
|
||||
if (inputNodes.length === 0 || nonInputNodes.length === 0) return false;
|
||||
|
||||
// Try to find a valid connection
|
||||
let attempts = 0;
|
||||
const maxAttempts = 20;
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
// Random from node (any node)
|
||||
const fromNode = genome.nodes[Math.floor(Math.random() * genome.nodes.length)];
|
||||
// Random to node (not input)
|
||||
const toNode = nonInputNodes[Math.floor(Math.random() * nonInputNodes.length)];
|
||||
|
||||
// Can't connect to itself
|
||||
if (fromNode.id === toNode.id) {
|
||||
attempts++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if connection already exists
|
||||
if (connectionExists(genome, fromNode.id, toNode.id)) {
|
||||
attempts++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if it would create a cycle
|
||||
if (wouldCreateCycle(genome, fromNode.id, toNode.id)) {
|
||||
attempts++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Valid connection!
|
||||
genome.connections.push({
|
||||
innovation: innovationTracker.getInnovation(fromNode.id, toNode.id),
|
||||
from: fromNode.id,
|
||||
to: toNode.id,
|
||||
weight: (Math.random() * 2 - 1) * 2, // [-2, 2]
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new node by splitting an existing connection
|
||||
*/
|
||||
function addNode(genome: Genome, innovationTracker: InnovationTracker): boolean {
|
||||
const enabledConnections = genome.connections.filter(c => c.enabled);
|
||||
if (enabledConnections.length === 0) return false;
|
||||
|
||||
// Pick a random enabled connection
|
||||
const conn = enabledConnections[Math.floor(Math.random() * enabledConnections.length)];
|
||||
|
||||
// Disable the old connection
|
||||
conn.enabled = false;
|
||||
|
||||
// Create new node
|
||||
const newNodeId = getNextNodeId(genome);
|
||||
genome.nodes.push({
|
||||
id: newNodeId,
|
||||
type: 'hidden',
|
||||
activation: 'tanh',
|
||||
});
|
||||
|
||||
// Create two new connections:
|
||||
// 1. from -> newNode (weight = 1.0)
|
||||
genome.connections.push({
|
||||
innovation: innovationTracker.getInnovation(conn.from, newNodeId),
|
||||
from: conn.from,
|
||||
to: newNodeId,
|
||||
weight: 1.0,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
// 2. newNode -> to (weight = old connection's weight)
|
||||
genome.connections.push({
|
||||
innovation: innovationTracker.getInnovation(newNodeId, conn.to),
|
||||
from: newNodeId,
|
||||
to: conn.to,
|
||||
weight: conn.weight,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a random connection's enabled state
|
||||
*/
|
||||
function toggleConnection(genome: Genome): boolean {
|
||||
if (genome.connections.length === 0) return false;
|
||||
|
||||
const conn = genome.connections[Math.floor(Math.random() * genome.connections.length)];
|
||||
conn.enabled = !conn.enabled;
|
||||
return true;
|
||||
}
|
||||
183
src/lib/neatArena/network.ts
Normal file
183
src/lib/neatArena/network.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import type { Genome, NodeGene, ConnectionGene, ActivationFunction } from './genome';
|
||||
|
||||
/**
|
||||
* Feedforward neural network built from a NEAT genome.
|
||||
*
|
||||
* The network is built by topologically sorting the nodes and
|
||||
* evaluating them in order to ensure feedforward behavior.
|
||||
*/
|
||||
|
||||
interface NetworkNode {
|
||||
id: number;
|
||||
activation: ActivationFunction;
|
||||
inputs: { weight: number; sourceId: number }[];
|
||||
value: number;
|
||||
}
|
||||
|
||||
export class NeuralNetwork {
|
||||
private inputNodes: number[];
|
||||
private outputNodes: number[];
|
||||
private nodes: Map<number, NetworkNode>;
|
||||
private evaluationOrder: number[];
|
||||
|
||||
constructor(genome: Genome) {
|
||||
this.inputNodes = [];
|
||||
this.outputNodes = [];
|
||||
this.nodes = new Map();
|
||||
this.evaluationOrder = [];
|
||||
|
||||
this.buildNetwork(genome);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the network from the genome
|
||||
*/
|
||||
private buildNetwork(genome: Genome): void {
|
||||
// Create network nodes
|
||||
for (const nodeGene of genome.nodes) {
|
||||
this.nodes.set(nodeGene.id, {
|
||||
id: nodeGene.id,
|
||||
activation: nodeGene.activation,
|
||||
inputs: [],
|
||||
value: 0,
|
||||
});
|
||||
|
||||
if (nodeGene.type === 'input') {
|
||||
this.inputNodes.push(nodeGene.id);
|
||||
} else if (nodeGene.type === 'output') {
|
||||
this.outputNodes.push(nodeGene.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Add connections
|
||||
for (const conn of genome.connections) {
|
||||
if (!conn.enabled) continue;
|
||||
|
||||
const targetNode = this.nodes.get(conn.to);
|
||||
if (targetNode) {
|
||||
targetNode.inputs.push({
|
||||
weight: conn.weight,
|
||||
sourceId: conn.from,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Compute evaluation order (topological sort)
|
||||
this.evaluationOrder = this.topologicalSort(genome);
|
||||
}
|
||||
|
||||
/**
|
||||
* Topological sort to determine evaluation order
|
||||
*/
|
||||
private topologicalSort(genome: Genome): number[] {
|
||||
const inDegree = new Map<number, number>();
|
||||
const adj = new Map<number, number[]>();
|
||||
|
||||
// Initialize
|
||||
for (const node of genome.nodes) {
|
||||
inDegree.set(node.id, 0);
|
||||
adj.set(node.id, []);
|
||||
}
|
||||
|
||||
// Build adjacency list and in-degrees
|
||||
for (const conn of genome.connections) {
|
||||
if (!conn.enabled) continue;
|
||||
|
||||
adj.get(conn.from)!.push(conn.to);
|
||||
inDegree.set(conn.to, (inDegree.get(conn.to) || 0) + 1);
|
||||
}
|
||||
|
||||
// Kahn's algorithm
|
||||
const queue: number[] = [];
|
||||
const order: number[] = [];
|
||||
|
||||
// Start with nodes that have no incoming edges
|
||||
for (const [nodeId, degree] of inDegree.entries()) {
|
||||
if (degree === 0) {
|
||||
queue.push(nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
while (queue.length > 0) {
|
||||
const nodeId = queue.shift()!;
|
||||
order.push(nodeId);
|
||||
|
||||
for (const neighbor of adj.get(nodeId) || []) {
|
||||
inDegree.set(neighbor, inDegree.get(neighbor)! - 1);
|
||||
if (inDegree.get(neighbor) === 0) {
|
||||
queue.push(neighbor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return order;
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate the network with inputs and return outputs
|
||||
*/
|
||||
activate(inputs: number[]): number[] {
|
||||
if (inputs.length !== this.inputNodes.length) {
|
||||
throw new Error(`Expected ${this.inputNodes.length} inputs, got ${inputs.length}`);
|
||||
}
|
||||
|
||||
// Reset all node values
|
||||
for (const node of this.nodes.values()) {
|
||||
node.value = 0;
|
||||
}
|
||||
|
||||
// Set input values
|
||||
for (let i = 0; i < this.inputNodes.length; i++) {
|
||||
const node = this.nodes.get(this.inputNodes[i])!;
|
||||
node.value = inputs[i];
|
||||
}
|
||||
|
||||
// Evaluate nodes in topological order
|
||||
for (const nodeId of this.evaluationOrder) {
|
||||
const node = this.nodes.get(nodeId)!;
|
||||
|
||||
// Skip input nodes (already set)
|
||||
if (this.inputNodes.includes(nodeId)) continue;
|
||||
|
||||
// Sum weighted inputs
|
||||
let sum = 0;
|
||||
for (const input of node.inputs) {
|
||||
const sourceNode = this.nodes.get(input.sourceId);
|
||||
if (sourceNode) {
|
||||
sum += sourceNode.value * input.weight;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply activation function
|
||||
node.value = this.applyActivation(sum, node.activation);
|
||||
}
|
||||
|
||||
// Collect output values
|
||||
return this.outputNodes.map(id => this.nodes.get(id)!.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply activation function
|
||||
*/
|
||||
private applyActivation(x: number, activation: ActivationFunction): number {
|
||||
switch (activation) {
|
||||
case 'tanh':
|
||||
return Math.tanh(x);
|
||||
case 'sigmoid':
|
||||
return 1 / (1 + Math.exp(-x));
|
||||
case 'relu':
|
||||
return Math.max(0, x);
|
||||
case 'linear':
|
||||
return x;
|
||||
default:
|
||||
return Math.tanh(x);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a neural network from a genome
|
||||
*/
|
||||
export function createNetwork(genome: Genome): NeuralNetwork {
|
||||
return new NeuralNetwork(genome);
|
||||
}
|
||||
160
src/lib/neatArena/reproduction.ts
Normal file
160
src/lib/neatArena/reproduction.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import type { Genome, InnovationTracker } from './genome';
|
||||
import type { Species } from './speciation';
|
||||
import { cloneGenome } from './genome';
|
||||
import { crossover } from './crossover';
|
||||
import { mutate, DEFAULT_MUTATION_RATES, type MutationRates } from './mutations';
|
||||
|
||||
/**
|
||||
* NEAT Reproduction
|
||||
*
|
||||
* Handles species-based selection, crossover, and offspring generation.
|
||||
* Implements elitism and proper offspring allocation.
|
||||
*/
|
||||
|
||||
export interface ReproductionConfig {
|
||||
elitePerSpecies: number;
|
||||
crossoverRate: number;
|
||||
interspeciesMatingRate: number;
|
||||
mutationRates: MutationRates;
|
||||
}
|
||||
|
||||
export const DEFAULT_REPRODUCTION_CONFIG: ReproductionConfig = {
|
||||
elitePerSpecies: 1,
|
||||
crossoverRate: 0.75,
|
||||
interspeciesMatingRate: 0.001,
|
||||
mutationRates: DEFAULT_MUTATION_RATES,
|
||||
};
|
||||
|
||||
/**
|
||||
* Reproduce a new generation from species
|
||||
*/
|
||||
export function reproduce(
|
||||
species: Species[],
|
||||
populationSize: number,
|
||||
innovationTracker: InnovationTracker,
|
||||
config: ReproductionConfig = DEFAULT_REPRODUCTION_CONFIG
|
||||
): Genome[] {
|
||||
const newGenomes: Genome[] = [];
|
||||
|
||||
// Calculate total adjusted fitness
|
||||
const totalAdjustedFitness = species.reduce((sum, s) => {
|
||||
return sum + s.members.reduce((sSum, g) => sSum + g.fitness, 0);
|
||||
}, 0);
|
||||
|
||||
if (totalAdjustedFitness === 0) {
|
||||
// If all fitness is 0, allocate equally
|
||||
const genomesPerSpecies = Math.floor(populationSize / species.length);
|
||||
|
||||
for (const spec of species) {
|
||||
const offspring = reproduceSpecies(
|
||||
spec,
|
||||
genomesPerSpecies,
|
||||
innovationTracker,
|
||||
config
|
||||
);
|
||||
newGenomes.push(...offspring);
|
||||
}
|
||||
} else {
|
||||
// Allocate offspring based on adjusted fitness
|
||||
for (const spec of species) {
|
||||
const speciesFitness = spec.members.reduce((sum, g) => sum + g.fitness, 0);
|
||||
const offspringCount = Math.max(
|
||||
1,
|
||||
Math.floor((speciesFitness / totalAdjustedFitness) * populationSize)
|
||||
);
|
||||
|
||||
const offspring = reproduceSpecies(
|
||||
spec,
|
||||
offspringCount,
|
||||
innovationTracker,
|
||||
config
|
||||
);
|
||||
newGenomes.push(...offspring);
|
||||
}
|
||||
}
|
||||
|
||||
// If we don't have enough genomes, fill with random mutations of best
|
||||
while (newGenomes.length < populationSize) {
|
||||
const bestGenome = getBestGenomeFromSpecies(species);
|
||||
const mutated = mutate(bestGenome, innovationTracker, config.mutationRates);
|
||||
newGenomes.push(mutated);
|
||||
}
|
||||
|
||||
// If we have too many, trim the worst
|
||||
if (newGenomes.length > populationSize) {
|
||||
newGenomes.sort((a, b) => b.fitness - a.fitness);
|
||||
newGenomes.length = populationSize;
|
||||
}
|
||||
|
||||
return newGenomes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reproduce offspring within a species
|
||||
*/
|
||||
function reproduceSpecies(
|
||||
species: Species,
|
||||
offspringCount: number,
|
||||
innovationTracker: InnovationTracker,
|
||||
config: ReproductionConfig
|
||||
): Genome[] {
|
||||
const offspring: Genome[] = [];
|
||||
|
||||
// Sort members by fitness
|
||||
const sorted = [...species.members].sort((a, b) => b.fitness - a.fitness);
|
||||
|
||||
// Elitism: keep best genomes unchanged
|
||||
const eliteCount = Math.min(config.elitePerSpecies, sorted.length, offspringCount);
|
||||
for (let i = 0; i < eliteCount; i++) {
|
||||
offspring.push(cloneGenome(sorted[i]));
|
||||
}
|
||||
|
||||
// Generate rest through crossover and mutation
|
||||
while (offspring.length < offspringCount) {
|
||||
let child: Genome;
|
||||
|
||||
// Select parents
|
||||
const parent1 = selectParent(sorted);
|
||||
const parent2 = sorted.length >= 2 ? selectParent(sorted) : null;
|
||||
|
||||
// Crossover if we have two different parents, otherwise clone
|
||||
if (parent2 && parent1 !== parent2 && Math.random() < config.crossoverRate) {
|
||||
child = crossover(parent1, parent2, innovationTracker);
|
||||
} else {
|
||||
child = cloneGenome(parent1);
|
||||
}
|
||||
|
||||
// Always mutate (except elites)
|
||||
mutate(child, innovationTracker, config.mutationRates);
|
||||
offspring.push(child);
|
||||
}
|
||||
|
||||
return offspring;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a parent using fitness-proportionate selection
|
||||
*/
|
||||
function selectParent(sortedGenomes: Genome[]): Genome {
|
||||
// Simple tournament selection (top 50%)
|
||||
const tournamentSize = Math.max(2, Math.floor(sortedGenomes.length * 0.5));
|
||||
const index = Math.floor(Math.random() * tournamentSize);
|
||||
return sortedGenomes[index];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the best genome from all species
|
||||
*/
|
||||
function getBestGenomeFromSpecies(species: Species[]): Genome {
|
||||
let best: Genome | null = null;
|
||||
|
||||
for (const spec of species) {
|
||||
for (const genome of spec.members) {
|
||||
if (!best || genome.fitness > best.fitness) {
|
||||
best = genome;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return best || species[0].members[0];
|
||||
}
|
||||
199
src/lib/neatArena/selfPlay.ts
Normal file
199
src/lib/neatArena/selfPlay.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import type { Genome } from './genome';
|
||||
import type { Population } from './evolution';
|
||||
import type { AgentAction } from './types';
|
||||
import { createSimulation, stepSimulation } from './simulation';
|
||||
import { createNetwork } from './network';
|
||||
import { generateObservation, observationToInputs } from './sensors';
|
||||
import { createFitnessTracker, updateFitness } from './fitness';
|
||||
import { SeededRandom } from './utils';
|
||||
|
||||
/**
|
||||
* Self-Play Scheduler
|
||||
*
|
||||
* Orchestrates training matches between genomes.
|
||||
* Each genome plays K opponents, with side swapping for fairness.
|
||||
*/
|
||||
|
||||
export interface MatchConfig {
|
||||
matchesPerGenome: number; // K
|
||||
mapSeed: number;
|
||||
maxTicks: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_MATCH_CONFIG: MatchConfig = {
|
||||
matchesPerGenome: 4,
|
||||
mapSeed: 12345,
|
||||
maxTicks: 600,
|
||||
};
|
||||
|
||||
interface MatchPairing {
|
||||
genome1Index: number;
|
||||
genome2Index: number;
|
||||
spawnPairId: number;
|
||||
swapSides: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate entire population using self-play
|
||||
*/
|
||||
export function evaluatePopulation(
|
||||
population: Population,
|
||||
config: MatchConfig = DEFAULT_MATCH_CONFIG
|
||||
): Population {
|
||||
const genomes = population.genomes;
|
||||
const K = config.matchesPerGenome;
|
||||
|
||||
// Initialize fitness trackers
|
||||
const fitnessTrackers = genomes.map((_, i) => ({
|
||||
totalFitness: 0,
|
||||
matchCount: 0,
|
||||
}));
|
||||
|
||||
// Generate deterministic pairings
|
||||
const pairings = generatePairings(genomes.length, K, population.generation);
|
||||
|
||||
// Run all matches
|
||||
for (const pairing of pairings) {
|
||||
const result = runMatch(
|
||||
genomes[pairing.genome1Index],
|
||||
genomes[pairing.genome2Index],
|
||||
pairing,
|
||||
config
|
||||
);
|
||||
|
||||
// Accumulate fitness
|
||||
fitnessTrackers[pairing.genome1Index].totalFitness += result.fitness1;
|
||||
fitnessTrackers[pairing.genome1Index].matchCount++;
|
||||
|
||||
fitnessTrackers[pairing.genome2Index].totalFitness += result.fitness2;
|
||||
fitnessTrackers[pairing.genome2Index].matchCount++;
|
||||
}
|
||||
|
||||
console.log('[SelfPlay] Ran', pairings.length, 'matches for', genomes.length, 'genomes');
|
||||
console.log('[SelfPlay] Sample fitness from first genome:', fitnessTrackers[0].totalFitness, '/', fitnessTrackers[0].matchCount);
|
||||
|
||||
// Average fitness across matches
|
||||
for (let i = 0; i < genomes.length; i++) {
|
||||
const tracker = fitnessTrackers[i];
|
||||
genomes[i].fitness = tracker.matchCount > 0
|
||||
? tracker.totalFitness / tracker.matchCount
|
||||
: 0;
|
||||
}
|
||||
|
||||
return { ...population, genomes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate deterministic match pairings
|
||||
*/
|
||||
function generatePairings(
|
||||
populationSize: number,
|
||||
K: number,
|
||||
seed: number
|
||||
): MatchPairing[] {
|
||||
const pairings: MatchPairing[] = [];
|
||||
const rng = new SeededRandom(seed);
|
||||
|
||||
for (let i = 0; i < populationSize; i++) {
|
||||
for (let k = 0; k < K; k++) {
|
||||
// Pick a random opponent (not self)
|
||||
let opponentIndex;
|
||||
do {
|
||||
opponentIndex = rng.nextInt(0, populationSize);
|
||||
} while (opponentIndex === i);
|
||||
|
||||
// Random spawn pair (0-4)
|
||||
const spawnPairId = rng.nextInt(0, 5);
|
||||
|
||||
// Each match is played twice with swapped sides
|
||||
pairings.push({
|
||||
genome1Index: i,
|
||||
genome2Index: opponentIndex,
|
||||
spawnPairId,
|
||||
swapSides: false,
|
||||
});
|
||||
|
||||
pairings.push({
|
||||
genome1Index: i,
|
||||
genome2Index: opponentIndex,
|
||||
spawnPairId,
|
||||
swapSides: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return pairings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a single match between two genomes
|
||||
*/
|
||||
function runMatch(
|
||||
genome1: Genome,
|
||||
genome2: Genome,
|
||||
pairing: MatchPairing,
|
||||
config: MatchConfig
|
||||
): { fitness1: number; fitness2: number } {
|
||||
// Swap genomes if needed for side fairness
|
||||
const g1 = pairing.swapSides ? genome2 : genome1;
|
||||
const g2 = pairing.swapSides ? genome1 : genome2;
|
||||
|
||||
// Create networks
|
||||
const network1 = createNetwork(g1);
|
||||
const network2 = createNetwork(g2);
|
||||
|
||||
// Create simulation
|
||||
let sim = createSimulation(config.mapSeed + pairing.spawnPairId, pairing.spawnPairId);
|
||||
|
||||
// Create fitness trackers
|
||||
let tracker1 = createFitnessTracker(0);
|
||||
let tracker2 = createFitnessTracker(1);
|
||||
|
||||
// Run simulation
|
||||
while (!sim.isOver && sim.tick < config.maxTicks) {
|
||||
// Get observations
|
||||
const obs1 = generateObservation(0, sim);
|
||||
const obs2 = generateObservation(1, sim);
|
||||
|
||||
// Get actions from networks
|
||||
const inputs1 = observationToInputs(obs1);
|
||||
const inputs2 = observationToInputs(obs2);
|
||||
|
||||
const outputs1 = network1.activate(inputs1);
|
||||
const outputs2 = network2.activate(inputs2);
|
||||
|
||||
const action1: AgentAction = {
|
||||
moveX: outputs1[0],
|
||||
moveY: outputs1[1],
|
||||
turn: outputs1[2],
|
||||
shoot: outputs1[3],
|
||||
};
|
||||
|
||||
const action2: AgentAction = {
|
||||
moveX: outputs2[0],
|
||||
moveY: outputs2[1],
|
||||
turn: outputs2[2],
|
||||
shoot: outputs2[3],
|
||||
};
|
||||
|
||||
// Step simulation
|
||||
sim = stepSimulation(sim, [action1, action2]);
|
||||
|
||||
// Update fitness
|
||||
tracker1 = updateFitness(tracker1, sim);
|
||||
tracker2 = updateFitness(tracker2, sim);
|
||||
}
|
||||
|
||||
// Swap fitness back if sides were swapped
|
||||
if (pairing.swapSides) {
|
||||
return {
|
||||
fitness1: tracker2.fitness,
|
||||
fitness2: tracker1.fitness,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
fitness1: tracker1.fitness,
|
||||
fitness2: tracker2.fitness,
|
||||
};
|
||||
}
|
||||
}
|
||||
232
src/lib/neatArena/sensors.ts
Normal file
232
src/lib/neatArena/sensors.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import type { Agent, SimulationState, Observation, RayHit, Vec2, Wall } from './types';
|
||||
import { SIMULATION_CONFIG } from './types';
|
||||
|
||||
/**
|
||||
* Sensor system for NEAT Arena.
|
||||
*
|
||||
* Agents perceive the world using 360° raycasting.
|
||||
* Each ray detects distance and what it hit (nothing, wall, or opponent).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generate observation vector for an agent.
|
||||
*
|
||||
* Returns a complete observation including:
|
||||
* - 24 rays (360°) with distance and hit type
|
||||
* - Agent's velocity
|
||||
* - Aim direction
|
||||
* - Fire cooldown
|
||||
*/
|
||||
export function generateObservation(agentId: number, state: SimulationState): Observation {
|
||||
const agent = state.agents.find(a => a.id === agentId)!;
|
||||
const opponent = state.agents.find(a => a.id !== agentId)!;
|
||||
|
||||
const { RAY_COUNT, RAY_RANGE, FIRE_COOLDOWN, AGENT_MAX_SPEED } = SIMULATION_CONFIG;
|
||||
|
||||
// Cast rays in 360°
|
||||
const rays: RayHit[] = [];
|
||||
const angleStep = (2 * Math.PI) / RAY_COUNT;
|
||||
|
||||
for (let i = 0; i < RAY_COUNT; i++) {
|
||||
const angle = i * angleStep;
|
||||
const ray = castRay(agent.position, angle, RAY_RANGE, state.map.walls, opponent);
|
||||
rays.push(ray);
|
||||
}
|
||||
|
||||
// Normalize velocity
|
||||
const vx = agent.velocity.x / AGENT_MAX_SPEED;
|
||||
const vy = agent.velocity.y / AGENT_MAX_SPEED;
|
||||
|
||||
// Aim direction as sin/cos
|
||||
const aimSin = Math.sin(agent.aimAngle);
|
||||
const aimCos = Math.cos(agent.aimAngle);
|
||||
|
||||
// Normalize cooldown
|
||||
const cooldown = agent.fireCooldown / FIRE_COOLDOWN;
|
||||
|
||||
return {
|
||||
rays,
|
||||
vx,
|
||||
vy,
|
||||
aimSin,
|
||||
aimCos,
|
||||
cooldown,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cast a single ray from origin in a direction, up to maxDist.
|
||||
*
|
||||
* Returns the closest hit: either wall, opponent, or nothing.
|
||||
*/
|
||||
function castRay(
|
||||
origin: Vec2,
|
||||
angle: number,
|
||||
maxDist: number,
|
||||
walls: Wall[],
|
||||
opponent: Agent
|
||||
): RayHit {
|
||||
const dir: Vec2 = {
|
||||
x: Math.cos(angle),
|
||||
y: Math.sin(angle),
|
||||
};
|
||||
|
||||
const rayEnd: Vec2 = {
|
||||
x: origin.x + dir.x * maxDist,
|
||||
y: origin.y + dir.y * maxDist,
|
||||
};
|
||||
|
||||
let closestDist = maxDist;
|
||||
let hitType: 'nothing' | 'wall' | 'opponent' = 'nothing';
|
||||
|
||||
// Check wall intersections
|
||||
for (const wall of walls) {
|
||||
const dist = rayAABBIntersection(origin, rayEnd, wall.rect);
|
||||
if (dist !== null && dist < closestDist) {
|
||||
closestDist = dist;
|
||||
hitType = 'wall';
|
||||
}
|
||||
}
|
||||
|
||||
// Check opponent intersection (treat as circle)
|
||||
const opponentDist = rayCircleIntersection(origin, dir, maxDist, opponent.position, opponent.radius);
|
||||
if (opponentDist !== null && opponentDist < closestDist) {
|
||||
closestDist = opponentDist;
|
||||
hitType = 'opponent';
|
||||
}
|
||||
|
||||
return {
|
||||
distance: closestDist / maxDist, // Normalize to [0, 1]
|
||||
hitType,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ray-AABB intersection.
|
||||
* Returns distance to intersection, or null if no hit.
|
||||
*/
|
||||
function rayAABBIntersection(
|
||||
origin: Vec2,
|
||||
end: Vec2,
|
||||
aabb: { minX: number; minY: number; maxX: number; maxY: number }
|
||||
): number | null {
|
||||
const dir: Vec2 = {
|
||||
x: end.x - origin.x,
|
||||
y: end.y - origin.y,
|
||||
};
|
||||
|
||||
const len = Math.sqrt(dir.x * dir.x + dir.y * dir.y);
|
||||
if (len === 0) return null;
|
||||
|
||||
dir.x /= len;
|
||||
dir.y /= len;
|
||||
|
||||
// Slab method
|
||||
const invDirX = dir.x === 0 ? Infinity : 1 / dir.x;
|
||||
const invDirY = dir.y === 0 ? Infinity : 1 / dir.y;
|
||||
|
||||
const tx1 = (aabb.minX - origin.x) * invDirX;
|
||||
const tx2 = (aabb.maxX - origin.x) * invDirX;
|
||||
const ty1 = (aabb.minY - origin.y) * invDirY;
|
||||
const ty2 = (aabb.maxY - origin.y) * invDirY;
|
||||
|
||||
const tmin = Math.max(Math.min(tx1, tx2), Math.min(ty1, ty2));
|
||||
const tmax = Math.min(Math.max(tx1, tx2), Math.max(ty1, ty2));
|
||||
|
||||
if (tmax < 0 || tmin > tmax || tmin > len) return null;
|
||||
|
||||
return tmin >= 0 ? tmin : tmax;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ray-circle intersection.
|
||||
* Returns distance to intersection, or null if no hit.
|
||||
*/
|
||||
function rayCircleIntersection(
|
||||
origin: Vec2,
|
||||
dir: Vec2,
|
||||
maxDist: number,
|
||||
circleCenter: Vec2,
|
||||
circleRadius: number
|
||||
): number | null {
|
||||
// Vector from ray origin to circle center
|
||||
const oc: Vec2 = {
|
||||
x: origin.x - circleCenter.x,
|
||||
y: origin.y - circleCenter.y,
|
||||
};
|
||||
|
||||
const a = dir.x * dir.x + dir.y * dir.y;
|
||||
const b = 2 * (oc.x * dir.x + oc.y * dir.y);
|
||||
const c = oc.x * oc.x + oc.y * oc.y - circleRadius * circleRadius;
|
||||
|
||||
const discriminant = b * b - 4 * a * c;
|
||||
|
||||
if (discriminant < 0) return null;
|
||||
|
||||
const sqrtDisc = Math.sqrt(discriminant);
|
||||
const t1 = (-b - sqrtDisc) / (2 * a);
|
||||
const t2 = (-b + sqrtDisc) / (2 * a);
|
||||
|
||||
// Return closest positive intersection within range
|
||||
if (t1 >= 0 && t1 <= maxDist) return t1;
|
||||
if (t2 >= 0 && t2 <= maxDist) return t2;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert observation to flat array of floats for neural network input.
|
||||
*
|
||||
* Total: 24 rays × 2 + 5 extra = 53 inputs
|
||||
*/
|
||||
export function observationToInputs(obs: Observation): number[] {
|
||||
const inputs: number[] = [];
|
||||
|
||||
// Rays: distance + hitType as scalar
|
||||
for (const ray of obs.rays) {
|
||||
inputs.push(ray.distance);
|
||||
|
||||
// Encode hitType as scalar
|
||||
let hitTypeScalar = 0;
|
||||
if (ray.hitType === 'wall') hitTypeScalar = 0.5;
|
||||
else if (ray.hitType === 'opponent') hitTypeScalar = 1.0;
|
||||
|
||||
inputs.push(hitTypeScalar);
|
||||
}
|
||||
|
||||
// Extra inputs
|
||||
inputs.push(obs.vx);
|
||||
inputs.push(obs.vy);
|
||||
inputs.push(obs.aimSin);
|
||||
inputs.push(obs.aimCos);
|
||||
inputs.push(obs.cooldown);
|
||||
|
||||
return inputs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if agent has clear line-of-sight to opponent.
|
||||
* Used for fitness calculation.
|
||||
*/
|
||||
export function hasLineOfSight(agent: Agent, opponent: Agent, walls: Wall[]): boolean {
|
||||
const dir: Vec2 = {
|
||||
x: opponent.position.x - agent.position.x,
|
||||
y: opponent.position.y - agent.position.y,
|
||||
};
|
||||
|
||||
const dist = Math.sqrt(dir.x * dir.x + dir.y * dir.y);
|
||||
if (dist === 0) return true;
|
||||
|
||||
dir.x /= dist;
|
||||
dir.y /= dist;
|
||||
|
||||
// Check if any wall blocks the line
|
||||
for (const wall of walls) {
|
||||
const hitDist = rayAABBIntersection(agent.position, opponent.position, wall.rect);
|
||||
if (hitDist !== null && hitDist < dist) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
286
src/lib/neatArena/simulation.ts
Normal file
286
src/lib/neatArena/simulation.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
import type {
|
||||
SimulationState,
|
||||
Agent,
|
||||
Bullet,
|
||||
AgentAction,
|
||||
Vec2,
|
||||
Wall,
|
||||
MatchResult,
|
||||
} from './types';
|
||||
import { SIMULATION_CONFIG } from './types';
|
||||
import { generateArenaMap } from './mapGenerator';
|
||||
|
||||
/**
|
||||
* Core simulation engine for the NEAT Arena.
|
||||
*
|
||||
* Deterministic, operates at fixed 30Hz timestep.
|
||||
* Handles agent movement, bullet physics, collisions, respawning, and scoring.
|
||||
*/
|
||||
|
||||
let nextBulletId = 0;
|
||||
|
||||
/**
|
||||
* Create a new simulation instance
|
||||
*/
|
||||
export function createSimulation(mapSeed: number, spawnPairId: number): SimulationState {
|
||||
const map = generateArenaMap(mapSeed);
|
||||
|
||||
// Get spawn points for the selected pair
|
||||
const spawns = map.spawnPoints.filter(sp => sp.pairId === spawnPairId);
|
||||
const spawn0 = spawns.find(sp => sp.side === 0)!.position;
|
||||
const spawn1 = spawns.find(sp => sp.side === 1)!.position;
|
||||
|
||||
const agents: [Agent, Agent] = [
|
||||
createAgent(0, spawn0),
|
||||
createAgent(1, spawn1),
|
||||
];
|
||||
|
||||
return {
|
||||
tick: 0,
|
||||
agents,
|
||||
bullets: [],
|
||||
map,
|
||||
isOver: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new agent
|
||||
*/
|
||||
function createAgent(id: number, spawnPoint: Vec2): Agent {
|
||||
return {
|
||||
id,
|
||||
position: { x: spawnPoint.x, y: spawnPoint.y },
|
||||
velocity: { x: 0, y: 0 },
|
||||
aimAngle: id === 0 ? 0 : Math.PI, // Face each other initially
|
||||
radius: SIMULATION_CONFIG.AGENT_RADIUS,
|
||||
invulnTicks: SIMULATION_CONFIG.RESPAWN_INVULN_TICKS,
|
||||
fireCooldown: 0,
|
||||
hits: 0,
|
||||
kills: 0,
|
||||
spawnPoint,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Step the simulation forward by one tick
|
||||
*/
|
||||
export function stepSimulation(
|
||||
state: SimulationState,
|
||||
actions: [AgentAction, AgentAction]
|
||||
): SimulationState {
|
||||
if (state.isOver) return state;
|
||||
|
||||
const newState = { ...state };
|
||||
newState.tick++;
|
||||
|
||||
// Update agents
|
||||
newState.agents = [
|
||||
updateAgent(state.agents[0], actions[0], state),
|
||||
updateAgent(state.agents[1], actions[1], state),
|
||||
];
|
||||
|
||||
// Update bullets
|
||||
newState.bullets = state.bullets
|
||||
.map(b => updateBullet(b, state))
|
||||
.filter(b => b !== null) as Bullet[];
|
||||
|
||||
// Check bullet-agent collisions
|
||||
checkCollisions(newState);
|
||||
|
||||
// Check episode termination
|
||||
if (newState.tick >= SIMULATION_CONFIG.MAX_TICKS) {
|
||||
newState.isOver = true;
|
||||
newState.result = createMatchResult(newState);
|
||||
} else if (newState.agents[0].kills >= SIMULATION_CONFIG.KILLS_TO_WIN ||
|
||||
newState.agents[1].kills >= SIMULATION_CONFIG.KILLS_TO_WIN) {
|
||||
newState.isOver = true;
|
||||
newState.result = createMatchResult(newState);
|
||||
}
|
||||
|
||||
return newState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a single agent
|
||||
*/
|
||||
function updateAgent(agent: Agent, action: AgentAction, state: SimulationState): Agent {
|
||||
const { DT, AGENT_MAX_SPEED, AGENT_TURN_RATE, FIRE_COOLDOWN, BULLET_SPAWN_OFFSET, BULLET_SPEED } = SIMULATION_CONFIG;
|
||||
|
||||
const newAgent = { ...agent };
|
||||
|
||||
// Decrease timers
|
||||
if (newAgent.invulnTicks > 0) newAgent.invulnTicks--;
|
||||
if (newAgent.fireCooldown > 0) newAgent.fireCooldown--;
|
||||
|
||||
// Update aim angle
|
||||
const turnAmount = action.turn * AGENT_TURN_RATE * DT;
|
||||
newAgent.aimAngle += turnAmount;
|
||||
|
||||
// Normalize angle to [-π, π]
|
||||
newAgent.aimAngle = ((newAgent.aimAngle + Math.PI) % (2 * Math.PI)) - Math.PI;
|
||||
|
||||
// Update velocity
|
||||
const moveLength = Math.sqrt(action.moveX * action.moveX + action.moveY * action.moveY);
|
||||
if (moveLength > 0) {
|
||||
newAgent.velocity.x = (action.moveX / moveLength) * AGENT_MAX_SPEED;
|
||||
newAgent.velocity.y = (action.moveY / moveLength) * AGENT_MAX_SPEED;
|
||||
} else {
|
||||
newAgent.velocity.x = 0;
|
||||
newAgent.velocity.y = 0;
|
||||
}
|
||||
|
||||
// Update position
|
||||
let newX = newAgent.position.x + newAgent.velocity.x * DT;
|
||||
let newY = newAgent.position.y + newAgent.velocity.y * DT;
|
||||
|
||||
// Check wall collisions and clamp position
|
||||
const testPos = { x: newX, y: newY };
|
||||
if (isAgentCollidingWithWalls(testPos, newAgent.radius, state.map.walls)) {
|
||||
// Simple response: stop movement
|
||||
newX = newAgent.position.x;
|
||||
newY = newAgent.position.y;
|
||||
newAgent.velocity.x = 0;
|
||||
newAgent.velocity.y = 0;
|
||||
}
|
||||
|
||||
newAgent.position.x = newX;
|
||||
newAgent.position.y = newY;
|
||||
|
||||
// Fire bullet
|
||||
if (action.shoot > 0.5 && newAgent.fireCooldown === 0) {
|
||||
newAgent.fireCooldown = FIRE_COOLDOWN;
|
||||
|
||||
// Spawn bullet in front of agent
|
||||
const bulletPos: Vec2 = {
|
||||
x: newAgent.position.x + Math.cos(newAgent.aimAngle) * BULLET_SPAWN_OFFSET,
|
||||
y: newAgent.position.y + Math.sin(newAgent.aimAngle) * BULLET_SPAWN_OFFSET,
|
||||
};
|
||||
|
||||
const bullet: Bullet = {
|
||||
id: nextBulletId++,
|
||||
position: bulletPos,
|
||||
velocity: {
|
||||
x: Math.cos(newAgent.aimAngle) * BULLET_SPEED,
|
||||
y: Math.sin(newAgent.aimAngle) * BULLET_SPEED,
|
||||
},
|
||||
ownerId: newAgent.id,
|
||||
ttl: SIMULATION_CONFIG.BULLET_TTL,
|
||||
};
|
||||
|
||||
state.bullets.push(bullet);
|
||||
}
|
||||
|
||||
return newAgent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a bullet
|
||||
*/
|
||||
function updateBullet(bullet: Bullet, state: SimulationState): Bullet | null {
|
||||
const { DT } = SIMULATION_CONFIG;
|
||||
|
||||
const newBullet = { ...bullet };
|
||||
newBullet.ttl--;
|
||||
|
||||
if (newBullet.ttl <= 0) return null;
|
||||
|
||||
// Update position
|
||||
newBullet.position.x += newBullet.velocity.x * DT;
|
||||
newBullet.position.y += newBullet.velocity.y * DT;
|
||||
|
||||
// Check wall collision
|
||||
if (isBulletCollidingWithWalls(newBullet.position, state.map.walls)) {
|
||||
return null; // Bullet destroyed
|
||||
}
|
||||
|
||||
return newBullet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for bullet-agent collisions and handle hits
|
||||
*/
|
||||
function checkCollisions(state: SimulationState): void {
|
||||
const bulletsToRemove = new Set<number>();
|
||||
|
||||
for (const bullet of state.bullets) {
|
||||
for (const agent of state.agents) {
|
||||
// Can't hit yourself or invulnerable agents
|
||||
if (bullet.ownerId === agent.id || agent.invulnTicks > 0) continue;
|
||||
|
||||
const dx = bullet.position.x - agent.position.x;
|
||||
const dy = bullet.position.y - agent.position.y;
|
||||
const distSq = dx * dx + dy * dy;
|
||||
|
||||
if (distSq < agent.radius * agent.radius) {
|
||||
// Hit!
|
||||
bulletsToRemove.add(bullet.id);
|
||||
|
||||
// Update scores
|
||||
agent.hits++;
|
||||
const shooter = state.agents.find(a => a.id === bullet.ownerId);
|
||||
if (shooter) shooter.kills++;
|
||||
|
||||
// Respawn agent
|
||||
agent.position.x = agent.spawnPoint.x;
|
||||
agent.position.y = agent.spawnPoint.y;
|
||||
agent.velocity.x = 0;
|
||||
agent.velocity.y = 0;
|
||||
agent.invulnTicks = SIMULATION_CONFIG.RESPAWN_INVULN_TICKS;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove bullets
|
||||
state.bullets = state.bullets.filter(b => !bulletsToRemove.has(b.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an agent collides with any walls
|
||||
*/
|
||||
function isAgentCollidingWithWalls(pos: Vec2, radius: number, walls: Wall[]): boolean {
|
||||
for (const wall of walls) {
|
||||
// AABB vs circle collision
|
||||
const closestX = Math.max(wall.rect.minX, Math.min(pos.x, wall.rect.maxX));
|
||||
const closestY = Math.max(wall.rect.minY, Math.min(pos.y, wall.rect.maxY));
|
||||
|
||||
const dx = pos.x - closestX;
|
||||
const dy = pos.y - closestY;
|
||||
const distSq = dx * dx + dy * dy;
|
||||
|
||||
if (distSq < radius * radius) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a bullet collides with any walls
|
||||
*/
|
||||
function isBulletCollidingWithWalls(pos: Vec2, walls: Wall[]): boolean {
|
||||
for (const wall of walls) {
|
||||
if (pos.x >= wall.rect.minX && pos.x <= wall.rect.maxX &&
|
||||
pos.y >= wall.rect.minY && pos.y <= wall.rect.maxY) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create match result
|
||||
*/
|
||||
function createMatchResult(state: SimulationState): MatchResult {
|
||||
const [a0, a1] = state.agents;
|
||||
|
||||
let winnerId = -1;
|
||||
if (a0.kills > a1.kills) winnerId = 0;
|
||||
else if (a1.kills > a0.kills) winnerId = 1;
|
||||
|
||||
return {
|
||||
winnerId,
|
||||
scores: [a0.kills, a1.kills],
|
||||
ticks: state.tick,
|
||||
};
|
||||
}
|
||||
202
src/lib/neatArena/speciation.ts
Normal file
202
src/lib/neatArena/speciation.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import type { Genome } from './genome';
|
||||
|
||||
/**
|
||||
* NEAT Speciation
|
||||
*
|
||||
* Groups genomes into species based on compatibility distance.
|
||||
* Implements dynamic threshold adjustment to target 6-10 species.
|
||||
*/
|
||||
|
||||
export interface Species {
|
||||
id: number;
|
||||
representative: Genome;
|
||||
members: Genome[];
|
||||
averageFitness: number;
|
||||
staleness: number; // Generations without improvement
|
||||
}
|
||||
|
||||
/**
|
||||
* Compatibility distance coefficients
|
||||
*/
|
||||
export interface CompatibilityConfig {
|
||||
excessCoeff: number; // c1
|
||||
disjointCoeff: number; // c2
|
||||
weightDiffCoeff: number; // c3
|
||||
}
|
||||
|
||||
export const DEFAULT_COMPATIBILITY_CONFIG: CompatibilityConfig = {
|
||||
excessCoeff: 1.0,
|
||||
disjointCoeff: 1.0,
|
||||
weightDiffCoeff: 0.4,
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate compatibility distance between two genomes
|
||||
* δ = c1*E/N + c2*D/N + c3*W
|
||||
*/
|
||||
export function compatibilityDistance(
|
||||
genome1: Genome,
|
||||
genome2: Genome,
|
||||
config: CompatibilityConfig = DEFAULT_COMPATIBILITY_CONFIG
|
||||
): number {
|
||||
const innovations1 = new Set(genome1.connections.map(c => c.innovation));
|
||||
const innovations2 = new Set(genome2.connections.map(c => c.innovation));
|
||||
|
||||
const max1 = Math.max(...Array.from(innovations1), 0);
|
||||
const max2 = Math.max(...Array.from(innovations2), 0);
|
||||
const maxInnovation = Math.max(max1, max2);
|
||||
|
||||
let matching = 0;
|
||||
let disjoint = 0;
|
||||
let excess = 0;
|
||||
let weightDiff = 0;
|
||||
|
||||
const conn1Map = new Map(genome1.connections.map(c => [c.innovation, c]));
|
||||
const conn2Map = new Map(genome2.connections.map(c => [c.innovation, c]));
|
||||
|
||||
// Count matching, disjoint, excess
|
||||
const allInnovations = new Set([...innovations1, ...innovations2]);
|
||||
|
||||
for (const innovation of allInnovations) {
|
||||
const c1 = conn1Map.get(innovation);
|
||||
const c2 = conn2Map.get(innovation);
|
||||
|
||||
if (c1 && c2) {
|
||||
// Matching gene
|
||||
matching++;
|
||||
weightDiff += Math.abs(c1.weight - c2.weight);
|
||||
} else {
|
||||
// Disjoint or excess
|
||||
// Excess genes are those with innovation > OTHER genome's max
|
||||
const isInGenome1 = innovations1.has(innovation);
|
||||
const isInGenome2 = innovations2.has(innovation);
|
||||
|
||||
if (isInGenome1 && innovation > max2) {
|
||||
excess++;
|
||||
} else if (isInGenome2 && innovation > max1) {
|
||||
excess++;
|
||||
} else {
|
||||
disjoint++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize by number of genes in larger genome
|
||||
const N = Math.max(genome1.connections.length, genome2.connections.length, 1);
|
||||
|
||||
// Average weight difference for matching genes
|
||||
const avgWeightDiff = matching > 0 ? weightDiff / matching : 0;
|
||||
|
||||
const delta =
|
||||
(config.excessCoeff * excess) / N +
|
||||
(config.disjointCoeff * disjoint) / N +
|
||||
config.weightDiffCoeff * avgWeightDiff;
|
||||
|
||||
return delta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign genomes to species
|
||||
*/
|
||||
export function speciate(
|
||||
genomes: Genome[],
|
||||
previousSpecies: Species[],
|
||||
compatibilityThreshold: number,
|
||||
config: CompatibilityConfig = DEFAULT_COMPATIBILITY_CONFIG
|
||||
): Species[] {
|
||||
const newSpecies: Species[] = [];
|
||||
let nextSpeciesId = previousSpecies.length > 0
|
||||
? Math.max(...previousSpecies.map(s => s.id)) + 1
|
||||
: 0;
|
||||
|
||||
// Update representatives from previous generation
|
||||
for (const species of previousSpecies) {
|
||||
if (species.members.length > 0) {
|
||||
// Pick a random member as the new representative
|
||||
species.representative = species.members[Math.floor(Math.random() * species.members.length)];
|
||||
species.members = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Assign each genome to a species
|
||||
for (const genome of genomes) {
|
||||
let foundSpecies = false;
|
||||
|
||||
// Try to match with existing species
|
||||
for (const species of previousSpecies) {
|
||||
const distance = compatibilityDistance(genome, species.representative, config);
|
||||
|
||||
if (distance < compatibilityThreshold) {
|
||||
species.members.push(genome);
|
||||
foundSpecies = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If no match, create new species
|
||||
if (!foundSpecies) {
|
||||
const newSpec: Species = {
|
||||
id: nextSpeciesId++,
|
||||
representative: genome,
|
||||
members: [genome],
|
||||
averageFitness: 0,
|
||||
staleness: 0,
|
||||
};
|
||||
previousSpecies.push(newSpec);
|
||||
}
|
||||
}
|
||||
|
||||
// Keep only species with members
|
||||
for (const species of previousSpecies) {
|
||||
if (species.members.length > 0) {
|
||||
// Calculate average fitness
|
||||
const totalFitness = species.members.reduce((sum, g) => sum + g.fitness, 0);
|
||||
species.averageFitness = totalFitness / species.members.length;
|
||||
|
||||
newSpecies.push(species);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Speciation] Threshold: ${compatibilityThreshold.toFixed(2)}, Species formed: ${newSpecies.length}`);
|
||||
if (newSpecies.length > 0) {
|
||||
console.log(`[Speciation] Species sizes:`, newSpecies.map(s => s.members.length));
|
||||
}
|
||||
|
||||
return newSpecies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust compatibility threshold to target a certain number of species
|
||||
*/
|
||||
export function adjustCompatibilityThreshold(
|
||||
currentThreshold: number,
|
||||
currentSpeciesCount: number,
|
||||
targetMin: number = 6,
|
||||
targetMax: number = 10
|
||||
): number {
|
||||
const adjustmentRate = 0.1;
|
||||
|
||||
if (currentSpeciesCount < targetMin) {
|
||||
// Too few species, make threshold more lenient
|
||||
return currentThreshold + adjustmentRate;
|
||||
} else if (currentSpeciesCount > targetMax) {
|
||||
// Too many species, make threshold stricter
|
||||
return Math.max(0.1, currentThreshold - adjustmentRate);
|
||||
}
|
||||
|
||||
return currentThreshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply fitness sharing within species
|
||||
*/
|
||||
export function applyFitnessSharing(species: Species[]): void {
|
||||
for (const spec of species) {
|
||||
const speciesSize = spec.members.length;
|
||||
|
||||
for (const genome of spec.members) {
|
||||
// Adjusted fitness = raw fitness / species size
|
||||
genome.fitness = genome.fitness / speciesSize;
|
||||
}
|
||||
}
|
||||
}
|
||||
129
src/lib/neatArena/training.worker.ts
Normal file
129
src/lib/neatArena/training.worker.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import type { Population } from './evolution';
|
||||
import type { EvolutionConfig } from './evolution';
|
||||
import { evaluatePopulation, DEFAULT_MATCH_CONFIG } from './selfPlay';
|
||||
import { evolveGeneration, createPopulation, getPopulationStats } from './evolution';
|
||||
|
||||
/**
|
||||
* NEAT Training Worker
|
||||
*
|
||||
* Runs training in a background thread to prevent UI blocking.
|
||||
* The main thread only handles visualization and UI updates.
|
||||
*/
|
||||
|
||||
export interface TrainingWorkerMessage {
|
||||
type: 'start' | 'pause' | 'step' | 'reset' | 'init';
|
||||
config?: EvolutionConfig;
|
||||
}
|
||||
|
||||
export interface TrainingWorkerResponse {
|
||||
type: 'update' | 'error' | 'ready';
|
||||
population?: Population;
|
||||
stats?: ReturnType<typeof getPopulationStats>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
let population: Population | null = null;
|
||||
let isRunning = false;
|
||||
let config: EvolutionConfig | null = null;
|
||||
|
||||
/**
|
||||
* Handle messages from main thread
|
||||
*/
|
||||
self.onmessage = async (e: MessageEvent<TrainingWorkerMessage>) => {
|
||||
const message = e.data;
|
||||
|
||||
try {
|
||||
switch (message.type) {
|
||||
case 'init':
|
||||
if (message.config) {
|
||||
config = message.config;
|
||||
population = createPopulation(config);
|
||||
sendUpdate();
|
||||
self.postMessage({ type: 'ready' } as TrainingWorkerResponse);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'start':
|
||||
isRunning = true;
|
||||
runTrainingLoop();
|
||||
break;
|
||||
|
||||
case 'pause':
|
||||
isRunning = false;
|
||||
break;
|
||||
|
||||
case 'step':
|
||||
if (population && config) {
|
||||
const stats = await runSingleGeneration();
|
||||
sendUpdate(stats);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'reset':
|
||||
if (config) {
|
||||
population = createPopulation(config);
|
||||
isRunning = false;
|
||||
sendUpdate();
|
||||
}
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
self.postMessage({
|
||||
type: 'error',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
} as TrainingWorkerResponse);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Run continuous training loop
|
||||
*/
|
||||
async function runTrainingLoop() {
|
||||
while (isRunning && population && config) {
|
||||
const stats = await runSingleGeneration();
|
||||
sendUpdate(stats);
|
||||
|
||||
// Yield to allow pause/stop messages to be processed
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a single generation
|
||||
*/
|
||||
async function runSingleGeneration(): Promise<ReturnType<typeof getPopulationStats> | null> {
|
||||
if (!population || !config) return null;
|
||||
|
||||
console.log('[Worker] Starting generation', population.generation);
|
||||
|
||||
// Evaluate population
|
||||
const evaluatedPop = evaluatePopulation(population, DEFAULT_MATCH_CONFIG);
|
||||
|
||||
// Check fitness after evaluation
|
||||
const fitnesses = evaluatedPop.genomes.map(g => g.fitness);
|
||||
const avgFit = fitnesses.reduce((a, b) => a + b, 0) / fitnesses.length;
|
||||
const maxFit = Math.max(...fitnesses);
|
||||
console.log('[Worker] After evaluation - Avg fitness:', avgFit.toFixed(2), 'Max:', maxFit.toFixed(2));
|
||||
|
||||
// Evolve to next generation
|
||||
population = evolveGeneration(evaluatedPop, config);
|
||||
|
||||
console.log('[Worker] Generation', population.generation, 'complete');
|
||||
|
||||
// IMPORTANT: Send stats from the EVALUATED population, not the evolved one
|
||||
// (evolved population has fitness reset to 0)
|
||||
return getPopulationStats(evaluatedPop);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send population update to main thread
|
||||
*/
|
||||
function sendUpdate(stats?: ReturnType<typeof getPopulationStats> | null) {
|
||||
if (!population) return;
|
||||
|
||||
self.postMessage({
|
||||
type: 'update',
|
||||
population,
|
||||
stats: stats || undefined,
|
||||
} as TrainingWorkerResponse);
|
||||
}
|
||||
204
src/lib/neatArena/types.ts
Normal file
204
src/lib/neatArena/types.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Core types for the NEAT Arena simulation.
|
||||
*
|
||||
* The simulation is deterministic and operates at a fixed 30Hz timestep.
|
||||
* All units are in a 512×512 logic space.
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// WORLD & MAP
|
||||
// ============================================================================
|
||||
|
||||
export interface Vec2 {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface AABB {
|
||||
minX: number;
|
||||
minY: number;
|
||||
maxX: number;
|
||||
maxY: number;
|
||||
}
|
||||
|
||||
export interface Wall {
|
||||
rect: AABB;
|
||||
}
|
||||
|
||||
export interface SpawnPoint {
|
||||
position: Vec2;
|
||||
/** Which spawn pair this belongs to (0-4) */
|
||||
pairId: number;
|
||||
/** Which side of the pair (0 or 1) */
|
||||
side: 0 | 1;
|
||||
}
|
||||
|
||||
export interface ArenaMap {
|
||||
/** Rectangular walls */
|
||||
walls: Wall[];
|
||||
/** Symmetric spawn point pairs (always 5 pairs = 10 total spawn points) */
|
||||
spawnPoints: SpawnPoint[];
|
||||
/** Map generation seed */
|
||||
seed: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AGENT
|
||||
// ============================================================================
|
||||
|
||||
export interface Agent {
|
||||
id: number;
|
||||
position: Vec2;
|
||||
velocity: Vec2;
|
||||
/** Current aim direction in radians */
|
||||
aimAngle: number;
|
||||
|
||||
/** Radius for collision */
|
||||
radius: number;
|
||||
|
||||
/** Invulnerability ticks remaining after respawn */
|
||||
invulnTicks: number;
|
||||
|
||||
/** Cooldown ticks until can fire again */
|
||||
fireCooldown: number;
|
||||
|
||||
/** Number of times hit this episode */
|
||||
hits: number;
|
||||
|
||||
/** Number of times this agent landed a hit */
|
||||
kills: number;
|
||||
|
||||
/** Assigned spawn point */
|
||||
spawnPoint: Vec2;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// BULLET
|
||||
// ============================================================================
|
||||
|
||||
export interface Bullet {
|
||||
id: number;
|
||||
position: Vec2;
|
||||
velocity: Vec2;
|
||||
/** Which agent fired this bullet */
|
||||
ownerId: number;
|
||||
/** Ticks until bullet auto-expires */
|
||||
ttl: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SIMULATION STATE
|
||||
// ============================================================================
|
||||
|
||||
export interface SimulationState {
|
||||
/** Current tick (increments at 30Hz) */
|
||||
tick: number;
|
||||
|
||||
/** Agents in the arena (always 2) */
|
||||
agents: [Agent, Agent];
|
||||
|
||||
/** Active bullets */
|
||||
bullets: Bullet[];
|
||||
|
||||
/** The arena map */
|
||||
map: ArenaMap;
|
||||
|
||||
/** Episode over? */
|
||||
isOver: boolean;
|
||||
|
||||
/** Match result after episode ends */
|
||||
result?: MatchResult;
|
||||
}
|
||||
|
||||
export interface MatchResult {
|
||||
/** Winner agent ID, or -1 for draw */
|
||||
winnerId: number;
|
||||
|
||||
/** Final scores */
|
||||
scores: [number, number];
|
||||
|
||||
/** Total ticks */
|
||||
ticks: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ACTIONS
|
||||
// ============================================================================
|
||||
|
||||
export interface AgentAction {
|
||||
/** Movement vector (will be normalized) */
|
||||
moveX: number;
|
||||
moveY: number;
|
||||
|
||||
/** Turn rate [-1..1] (scaled by max turn rate) */
|
||||
turn: number;
|
||||
|
||||
/** Fire bullet if > 0.5 */
|
||||
shoot: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OBSERVATIONS / SENSORS
|
||||
// ============================================================================
|
||||
|
||||
export interface RayHit {
|
||||
/** Distance [0..1] normalized by max range */
|
||||
distance: number;
|
||||
|
||||
/** What the ray hit */
|
||||
hitType: 'nothing' | 'wall' | 'opponent';
|
||||
}
|
||||
|
||||
export interface Observation {
|
||||
/** 24 rays × 2 values (distance, hitType) */
|
||||
rays: RayHit[];
|
||||
|
||||
/** Agent's own velocity */
|
||||
vx: number;
|
||||
vy: number;
|
||||
|
||||
/** Aim direction as unit vector */
|
||||
aimSin: number;
|
||||
aimCos: number;
|
||||
|
||||
/** Fire cooldown [0..1] */
|
||||
cooldown: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SIMULATION CONFIG
|
||||
// ============================================================================
|
||||
|
||||
export const SIMULATION_CONFIG = {
|
||||
/** Logic world size */
|
||||
WORLD_SIZE: 512,
|
||||
|
||||
/** Fixed timestep (30Hz) */
|
||||
TICK_RATE: 30,
|
||||
DT: 1 / 30,
|
||||
|
||||
/** Episode termination */
|
||||
MAX_TICKS: 600, // 20 seconds
|
||||
KILLS_TO_WIN: 5,
|
||||
|
||||
/** Agent physics */
|
||||
AGENT_RADIUS: 8,
|
||||
AGENT_MAX_SPEED: 120, // units/sec
|
||||
AGENT_TURN_RATE: 270 * (Math.PI / 180), // rad/sec
|
||||
|
||||
/** Respawn */
|
||||
RESPAWN_INVULN_TICKS: 15, // 0.5 seconds
|
||||
|
||||
/** Bullet physics */
|
||||
BULLET_SPEED: 260, // units/sec
|
||||
BULLET_TTL: 60, // 2 seconds
|
||||
FIRE_COOLDOWN: 10, // ~0.33 seconds
|
||||
BULLET_SPAWN_OFFSET: 12, // spawn in front of agent
|
||||
|
||||
/** Sensors */
|
||||
RAY_COUNT: 24,
|
||||
RAY_RANGE: 220,
|
||||
} as const;
|
||||
|
||||
// Re-export Genome type from genome module for convenience
|
||||
export type { Genome } from './genome';
|
||||
42
src/lib/neatArena/utils.ts
Normal file
42
src/lib/neatArena/utils.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Deterministic random number generator using a linear congruential generator (LCG).
|
||||
*
|
||||
* Ensures reproducible results for the same seed.
|
||||
*/
|
||||
export class SeededRandom {
|
||||
private seed: number;
|
||||
|
||||
constructor(seed: number) {
|
||||
this.seed = seed % 2147483647;
|
||||
if (this.seed <= 0) this.seed += 2147483646;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a float in [0, 1)
|
||||
*/
|
||||
next(): number {
|
||||
this.seed = (this.seed * 16807) % 2147483647;
|
||||
return (this.seed - 1) / 2147483646;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an integer in [min, max) (max exclusive)
|
||||
*/
|
||||
nextInt(min: number, max: number): number {
|
||||
return Math.floor(this.next() * (max - min)) + min;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a float in [min, max)
|
||||
*/
|
||||
nextFloat(min: number, max: number): number {
|
||||
return this.next() * (max - min) + min;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a random boolean
|
||||
*/
|
||||
nextBool(): boolean {
|
||||
return this.next() < 0.5;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user