Felladrin's picture
Add the simulator files
17fd0c2 verified
import { distance, clamp, TAU, COLORS, randomRange } from "./utils.js";
import { calculateFitness, NET_TOPOLOGY } from "./genetics.js";
import { QuantumDecisionLayer } from "./neuralNetwork.js";
let nextEntityId = 1;
const _glowCache = {};
function getGlowSprite(type, size) {
const key = `${type}_${Math.round(size)}`;
if (_glowCache[key]) return _glowCache[key];
const c = COLORS[type] || COLORS.cyan;
const spriteSize = Math.ceil(size * 5);
const canvas = document.createElement("canvas");
canvas.width = spriteSize;
canvas.height = spriteSize;
const ctx = canvas.getContext("2d");
const cx = spriteSize / 2;
const grad = ctx.createRadialGradient(cx, cx, size * 0.3, cx, cx, cx);
grad.addColorStop(0, `rgba(${c.r},${c.g},${c.b},0.3)`);
grad.addColorStop(0.4, `rgba(${c.r},${c.g},${c.b},0.1)`);
grad.addColorStop(1, `rgba(${c.r},${c.g},${c.b},0)`);
ctx.fillStyle = grad;
ctx.fillRect(0, 0, spriteSize, spriteSize);
_glowCache[key] = canvas;
return canvas;
}
export class Entity {
constructor(x, y, genome, generation = 0) {
this.id = nextEntityId++;
this.x = x;
this.y = y;
this.genome = genome;
this.type = genome.type;
this.generation = generation;
this.brain = genome.buildBrain();
this.quantumLayer = generation > 5 ? new QuantumDecisionLayer(0.8) : null;
const t = genome.traits;
this.energy = 60;
this.maxEnergy = 100;
this.health = 100;
this.maxHealth = 100;
this.speed = t.speed;
this.size = t.size;
this.visionRange = t.vision;
this.attackPower = t.attack;
this.defensePower = t.defense;
this.metabolism = t.metabolism;
this.vx = 0;
this.vy = 0;
this.angle = Math.random() * TAU;
this.age = 0;
this.alive = true;
this.signal = 0;
this.signalStrength = 0;
this.stats = { survivalTime: 0, energyGathered: 0, offspring: 0, kills: 0 };
this.fitness = 0;
this._thinkTimer = Math.random() * 0.15;
this._thinkInterval = 0.12;
this._reproTimer = 0;
this._reproInterval = 5;
this.lastOutputs = new Array(NET_TOPOLOGY[NET_TOPOLOGY.length - 1]).fill(0);
this._trail = [];
this._trailTimer = 0;
this._nearbyEntities = [];
this._nearbyResources = [];
}
update(dt, world, _allEntities, spatialGrid, particles) {
if (!this.alive) return;
this.age += dt;
this.stats.survivalTime = this.age;
this.energy -= this.metabolism * dt * 0.5;
const envDmg = world.getCatastropheDamage(this.x, this.y);
if (envDmg > 0) {
this.health -= envDmg * dt * 30;
}
if (this.energy <= 0 || this.health <= 0) {
this.alive = false;
particles.emit(this.x, this.y, "death", this._colorArray());
return;
}
this._thinkTimer += dt;
if (this._thinkTimer >= this._thinkInterval) {
this._thinkTimer = 0;
this._sense(world, spatialGrid);
this._think();
}
this._act(dt, world, spatialGrid, particles);
this._trailTimer += dt;
if (this._trailTimer > 0.08) {
this._trailTimer = 0;
this._trail.push({ x: this.x, y: this.y });
if (this._trail.length > 8) this._trail.shift();
}
this._reproTimer += dt;
this.fitness = calculateFitness(this.type, this.stats);
}
_sense(world, spatialGrid) {
this._nearbyEntities = spatialGrid.query(this.x, this.y, this.visionRange);
this._nearbyResources = world.getResourcesNear(
this.x,
this.y,
this.visionRange,
);
}
_think() {
const inputs = this._buildInputs();
const outputs = this.brain.forward(inputs);
this.lastOutputs = outputs;
}
_buildInputs() {
const inputs = new Array(NET_TOPOLOGY[0]).fill(0);
const vr = this.visionRange;
let nearestFood = null,
nearestFoodDist = Infinity;
for (const r of this._nearbyResources) {
const d = distance(this.x, this.y, r.x, r.y);
if (d < nearestFoodDist) {
nearestFoodDist = d;
nearestFood = r;
}
}
if (nearestFood) {
inputs[0] = (nearestFood.x - this.x) / vr;
inputs[1] = (nearestFood.y - this.y) / vr;
inputs[2] = 1 - nearestFoodDist / vr;
}
let nearestThreat = null,
nearestThreatDist = Infinity;
let nearestAlly = null,
nearestAllyDist = Infinity;
let allySignalSum = 0,
allyCount = 0;
let nearbyCount = 0;
for (const e of this._nearbyEntities) {
if (e.id === this.id || !e.alive) continue;
const d = distance(this.x, this.y, e.x, e.y);
nearbyCount++;
const isThreat =
(this.type !== "predator" && e.type === "predator") ||
e.attackPower > this.defensePower * 1.5;
if (isThreat && d < nearestThreatDist) {
nearestThreatDist = d;
nearestThreat = e;
}
const isAlly = e.type === this.type;
if (isAlly && d < nearestAllyDist) {
nearestAllyDist = d;
nearestAlly = e;
allySignalSum += e.signal;
allyCount++;
}
}
if (nearestThreat) {
inputs[3] = (nearestThreat.x - this.x) / vr;
inputs[4] = (nearestThreat.y - this.y) / vr;
inputs[5] = 1 - nearestThreatDist / vr;
}
if (nearestAlly) {
inputs[6] = (nearestAlly.x - this.x) / vr;
inputs[7] = (nearestAlly.y - this.y) / vr;
}
inputs[8] = this.energy / this.maxEnergy;
inputs[9] = this.health / this.maxHealth;
inputs[10] = clamp(nearbyCount / 10, 0, 1);
inputs[11] = allyCount > 0 ? allySignalSum / allyCount / 7 : 0;
inputs[12] = Math.sin(this.age * 0.5);
inputs[13] = 1;
return inputs;
}
_act(dt, world, spatialGrid, particles) {
const o = this.lastOutputs;
const moveX = o[0];
const moveY = o[1];
const moveLen = Math.sqrt(moveX * moveX + moveY * moveY);
if (moveLen > 0.01) {
const nx = moveX / moveLen;
const ny = moveY / moveLen;
this.vx = nx * this.speed * 30;
this.vy = ny * this.speed * 30;
this.angle = Math.atan2(ny, nx);
} else {
this.vx *= 0.9;
this.vy *= 0.9;
}
this.x += this.vx * dt;
this.y += this.vy * dt;
if (this.x < 0) this.x += world.width;
if (this.x >= world.width) this.x -= world.width;
if (this.y < 0) this.y += world.height;
if (this.y >= world.height) this.y -= world.height;
this._tryEat(world, particles, o[2]);
if (o[3] > 0.3 && this.attackPower > 0.3) {
this._tryAttack(spatialGrid, particles);
}
this.signal = Math.floor((o[5] + 1) * 4) % 8;
this.signalStrength = Math.abs(o[5]);
if (this.signalStrength > 0.5) {
particles.emit(this.x, this.y, "signal", [
COLORS[this.type].r,
COLORS[this.type].g,
COLORS[this.type].b,
]);
}
}
_tryEat(world, particles, eagerness = 0) {
const eatRange = this.size + 20;
const resources = world.getResourcesNear(this.x, this.y, eatRange);
for (const r of resources) {
const d = distance(this.x, this.y, r.x, r.y);
if (d < eatRange) {
const efficiency = 0.4 + Math.max(0, eagerness) * 0.6;
const amount = r.consume(6 * efficiency);
this.energy = Math.min(this.maxEnergy, this.energy + amount * 1.5);
this.stats.energyGathered += amount;
if (amount > 0.5) particles.emit(r.x, r.y, "eat");
break;
}
}
}
_tryAttack(spatialGrid, particles) {
const attackRange = this.size + 12;
const nearby = spatialGrid.query(this.x, this.y, attackRange);
for (const e of nearby) {
if (e.id === this.id || !e.alive) continue;
if (e.type === this.type) continue;
const d = distance(this.x, this.y, e.x, e.y);
if (d < attackRange) {
const dmg = this.attackPower * 15 - e.defensePower * 5;
if (dmg > 0) {
e.health -= dmg;
this.energy = Math.min(this.maxEnergy, this.energy + dmg * 0.3);
particles.emit(e.x, e.y, "attack");
if (e.health <= 0) {
e.alive = false;
this.stats.kills++;
this.energy = Math.min(this.maxEnergy, this.energy + 20);
particles.emit(e.x, e.y, "death", this._colorArray());
}
}
break;
}
}
}
canReproduce() {
return (
this.alive &&
this.energy > 40 &&
this._reproTimer >= this._reproInterval &&
this.age > 2
);
}
tryReproduce(spatialGrid, particles) {
if (!this.canReproduce() || this.lastOutputs[4] < -0.2) return null;
const mateRange = this.visionRange * 0.5;
const nearby = spatialGrid.query(this.x, this.y, mateRange);
for (const e of nearby) {
if (e.id === this.id || !e.alive || e.type !== this.type) continue;
if (!e.canReproduce()) continue;
this.energy -= 18;
e.energy -= 18;
this._reproTimer = 0;
e._reproTimer = 0;
this.stats.offspring++;
e.stats.offspring++;
const childGenome = this.genome.crossover(e.genome);
childGenome.mutate(0.2, 0.06, 0.25);
const cx = (this.x + e.x) / 2 + randomRange(-10, 10);
const cy = (this.y + e.y) / 2 + randomRange(-10, 10);
const child = new Entity(
cx,
cy,
childGenome,
Math.max(this.generation, e.generation) + 1,
);
particles.emit(cx, cy, "reproduce", [
COLORS[this.type].r,
COLORS[this.type].g,
COLORS[this.type].b,
]);
return child;
}
return null;
}
_colorArray() {
const c = COLORS[this.type];
return c ? [c.r, c.g, c.b] : [255, 255, 255];
}
render(ctx) {
if (!this.alive) return;
const c = COLORS[this.type] || COLORS.cyan;
const pulse = 0.85 + 0.15 * Math.sin(this.age * 3 + this.id);
const energyRatio = this.energy / this.maxEnergy;
if (this._trail.length > 1) {
ctx.strokeStyle = `rgba(${c.r},${c.g},${c.b},0.15)`;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(this._trail[0].x, this._trail[0].y);
for (let i = 1; i < this._trail.length; i++) {
ctx.lineTo(this._trail[i].x, this._trail[i].y);
}
ctx.stroke();
}
const glow = getGlowSprite(this.type, this.size);
const glowSize = glow.width;
ctx.drawImage(glow, this.x - glowSize / 2, this.y - glowSize / 2);
ctx.strokeStyle = `rgba(${c.r},${c.g},${c.b},0.5)`;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(this.x, this.y, this.size * pulse, 0, TAU);
ctx.stroke();
ctx.fillStyle = `rgba(${c.r},${c.g},${c.b},0.65)`;
ctx.beginPath();
ctx.arc(this.x, this.y, this.size * 0.8 * pulse, 0, TAU);
ctx.fill();
ctx.fillStyle = `rgba(${Math.min(255, c.r + 100)},${Math.min(255, c.g + 100)},${Math.min(255, c.b + 100)},0.9)`;
ctx.beginPath();
ctx.arc(this.x, this.y, this.size * 0.3, 0, TAU);
ctx.fill();
ctx.fillStyle = `rgba(${c.r},${c.g},${c.b},0.45)`;
const s = this.size * 0.55;
switch (this.type) {
case "predator":
this._drawTriangle(ctx, this.x, this.y, s);
break;
case "builder":
ctx.fillRect(this.x - s * 0.6, this.y - s * 0.6, s * 1.2, s * 1.2);
break;
case "explorer":
this._drawDiamond(ctx, this.x, this.y, s);
break;
case "hybrid":
this._drawStar(ctx, this.x, this.y, s, 5);
break;
default:
ctx.strokeStyle = `rgba(${c.r},${c.g},${c.b},0.3)`;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(this.x, this.y, this.size * 0.5, 0, TAU);
ctx.stroke();
}
const dirLen = this.size + 5;
ctx.strokeStyle = `rgba(${c.r},${c.g},${c.b},0.6)`;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(this.x, this.y);
ctx.lineTo(
this.x + Math.cos(this.angle) * dirLen,
this.y + Math.sin(this.angle) * dirLen,
);
ctx.stroke();
if (energyRatio < 0.9) {
ctx.strokeStyle = `rgba(${c.r},${c.g},${c.b},0.3)`;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(
this.x,
this.y,
this.size + 3,
-Math.PI / 2,
-Math.PI / 2 + TAU * energyRatio,
);
ctx.stroke();
}
if (this.health < this.maxHealth * 0.9) {
const barW = this.size * 3;
const barH = 2.5;
const barX = this.x - barW / 2;
const barY = this.y - this.size - 8;
ctx.fillStyle = "rgba(60,20,20,0.6)";
ctx.fillRect(barX, barY, barW, barH);
const healthRatio = this.health / this.maxHealth;
ctx.fillStyle =
healthRatio > 0.5
? `rgba(0,255,80,0.7)`
: `rgba(255,${Math.floor(healthRatio * 2 * 255)},80,0.7)`;
ctx.fillRect(barX, barY, barW * healthRatio, barH);
}
if (this.signalStrength > 0.4) {
ctx.strokeStyle = `rgba(${c.r},${c.g},${c.b},${this.signalStrength * 0.2})`;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(this.x, this.y, this.size + 8 + this.signalStrength * 5, 0, TAU);
ctx.stroke();
}
}
_drawTriangle(ctx, cx, cy, s) {
ctx.beginPath();
ctx.moveTo(cx, cy - s);
ctx.lineTo(cx - s * 0.87, cy + s * 0.5);
ctx.lineTo(cx + s * 0.87, cy + s * 0.5);
ctx.closePath();
ctx.fill();
}
_drawDiamond(ctx, cx, cy, s) {
ctx.beginPath();
ctx.moveTo(cx, cy - s);
ctx.lineTo(cx + s, cy);
ctx.lineTo(cx, cy + s);
ctx.lineTo(cx - s, cy);
ctx.closePath();
ctx.fill();
}
_drawStar(ctx, cx, cy, r, points) {
ctx.beginPath();
for (let i = 0; i < points * 2; i++) {
const rad = i % 2 === 0 ? r : r * 0.4;
const angle = (i * Math.PI) / points - Math.PI / 2;
const px = cx + Math.cos(angle) * rad;
const py = cy + Math.sin(angle) * rad;
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.closePath();
ctx.fill();
}
}