| 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(); |
| } |
| } |
|
|