diff --git a/assets/shaders/blur/fragment.glsl b/assets/shaders/blur/fragment.glsl index 1420d19..8999683 100644 --- a/assets/shaders/blur/fragment.glsl +++ b/assets/shaders/blur/fragment.glsl @@ -42,9 +42,9 @@ void main(){ // filter_color += 0.25 * weight[i] * texture2D(u_sample2D, v_texCoord0 - vec2(offset[j], 0.0) / u_resolution) * v_color; // } // } - float skip = 1; + float skip = 1.0; vec4 filter_color = vec4(0.); - int filter_radius = 3; // int(u_blurRadius); + float filter_radius = 3.0; // int(u_blurRadius); float sd = filter_radius * skip; float k = 1.0 / (2.0 * sd * sd); float sum = 0.0; diff --git a/assets/shaders/chemical/fragment.glsl b/assets/shaders/chemical/fragment.glsl index 3fe6013..816abd2 100644 --- a/assets/shaders/chemical/fragment.glsl +++ b/assets/shaders/chemical/fragment.glsl @@ -10,7 +10,7 @@ void main(){ float skipX = 0.3 * u_blurAmount; float skipY = 0.3 * u_blurAmount * u_resolution.y / u_resolution.x; vec4 filter_color = vec4(0.); - int filter_radius = 8; + float filter_radius = 8.0; float sd = filter_radius * (skipX + skipY) / 2.0; float k = 1.0 / (2.0 * sd * sd); float sum = 0.0; diff --git a/assets/shaders/pause/fragment.glsl b/assets/shaders/pause/fragment.glsl index f970e9a..c71ff47 100644 --- a/assets/shaders/pause/fragment.glsl +++ b/assets/shaders/pause/fragment.glsl @@ -11,7 +11,7 @@ void main(){ float skipX = 0.3 * u_blurAmount; float skipY = 0.3 * u_blurAmount * u_resolution.y / u_resolution.x; vec4 filter_color = vec4(0.); - int filter_radius = 8; + float filter_radius = 8.0; float sd = filter_radius * (skipX + skipY) / 2.0; float k = 1.0 / (2.0 * sd * sd); float sum = 0.0; diff --git a/core/build.gradle b/core/build.gradle index a651ce3..9245cf1 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -1,6 +1,6 @@ //sourceCompatibility = 1.8 -sourceCompatibility = 23 -targetCompatibility = 23 +sourceCompatibility = 21 +targetCompatibility = 21 dependencies { implementation 'junit:junit:4.13.1' } diff --git a/core/src/com/protoevo/biology/BurstRequest.java b/core/src/com/protoevo/biology/BurstRequest.java index 7a7bf47..ba6a53f 100644 --- a/core/src/com/protoevo/biology/BurstRequest.java +++ b/core/src/com/protoevo/biology/BurstRequest.java @@ -98,6 +98,10 @@ public void burst() { childParticle.applyImpulse(dir.scl(.005f)); child.setGeneration(parent.getGeneration() + 1); + // Inherit phylogeny tags so the lineage view can group + // descendants under their founder ancestor. + child.setLineageId(parent.getLineageId()); + child.setParentId(parent.getId()); allocateChildResources(child, p); angle += 2 * Math.PI / nChildren; diff --git a/core/src/com/protoevo/biology/CauseOfDeath.java b/core/src/com/protoevo/biology/CauseOfDeath.java index af23bf5..aa18aa5 100644 --- a/core/src/com/protoevo/biology/CauseOfDeath.java +++ b/core/src/com/protoevo/biology/CauseOfDeath.java @@ -21,7 +21,8 @@ public enum CauseOfDeath { OVERCROWDING("overcrowding", false), MEAT_DECAY("decay", true), HYPOTHERMIA("hypothermia", false), - HYPERTHERMIA("hyperthermia", false); + HYPERTHERMIA("hyperthermia", false), + STARVATION("starvation", false); private final String reason; private final boolean debug; diff --git a/core/src/com/protoevo/biology/ComplexMolecule.java b/core/src/com/protoevo/biology/ComplexMolecule.java index 6903901..5e26fae 100644 --- a/core/src/com/protoevo/biology/ComplexMolecule.java +++ b/core/src/com/protoevo/biology/ComplexMolecule.java @@ -15,7 +15,7 @@ public class ComplexMolecule implements Serializable { private float signature, productionCost; - private final static Map cache = new HashMap<>( + private final static Map cache = new java.util.concurrent.ConcurrentHashMap<>( Environment.settings.possibleMolecules.get(), 1); public ComplexMolecule() {} diff --git a/core/src/com/protoevo/biology/Food.java b/core/src/com/protoevo/biology/Food.java index 280de82..f566177 100644 --- a/core/src/com/protoevo/biology/Food.java +++ b/core/src/com/protoevo/biology/Food.java @@ -57,7 +57,11 @@ public void addSimpleMass(float m) { } public void addEnergy(float energy) { - mass += energy; + // Was: `mass += energy;` — clearly a typo bug. Combined with `addSimpleMass` + // double-adding mass and `getEnergy` re-releasing the stored value every + // tick at meat density (3e5×), this let cells eat their own corpses for + // a 6-figure-multiplier net energy gain. Storing in the right field now. + this.energy += energy; } public void subtractSimpleMass(float m) { @@ -99,8 +103,30 @@ public void addComplexMoleculeMass(ComplexMolecule molecule, float mass) { complexMoleculeMasses.put(molecule, currentMass + mass); } + /** + * Release energy proportional to the mass `m` just extracted. Consumes the + * proportional fraction of the stored `energy` field, plus density × m for + * the meat/plant intrinsic energy. + * + * Caller in Cell.digest subtracts mass FIRST, then calls this with the + * removed mass. So `mass` here is the *remaining* mass after extraction; + * `mass + m` reconstructs the pre-extraction total. + * + * Was: `return energy + density * m;` — released the whole stored energy + * every tick, never decrementing it. Combined with the addEnergy/mass-typo + * bug above, this was the main "eat your own meat for infinite energy" loop. + */ public float getEnergy(float m) { - return energy + type.getEnergyDensity() * m; + float prevMass = mass + m; + float storedReleased; + if (prevMass <= 0f) { + // Edge case: no mass left to attribute the release to. Take it all. + storedReleased = energy; + } else { + storedReleased = energy * (m / prevMass); + } + energy = Math.max(0f, energy - storedReleased); + return storedReleased + type.getEnergyDensity() * m; } @Override @@ -114,10 +140,25 @@ public float getDecayRate() { } public void decay(float delta) { - float decay = Math.max(0, 1f - getDecayRate() * delta); - mass = Math.max(0, mass - decay); - energy = Math.max(0, energy - decay); - complexMoleculeMasses.replaceAll((m, v) -> complexMoleculeMasses.get(m) - * Environment.settings.cell.complexMoleculeDecayRate.get()); + // Geometric decay: each tick retain (1 - rate * delta) of mass and + // energy. Previous formula subtracted `1 - rate*delta` (which is ≈1) + // from mass and energy every tick — for plantDecayRate=1e-4 and + // delta=1e-3, the subtracted amount was ≈0.9999999, so food.mass + // and food.energy were zeroed within ONE tick of being created. + // That made the foodToDigest pool effectively non-functional: + // eat() added a tiny extraction per tick, decay zeroed it, digest() + // saw a near-empty food chunk, and cells appeared to get nothing + // from eating plants (energy fell, construction mass stayed at 0). + // + // The complex-molecule pass was also broken — it multiplied each + // value by `complexMoleculeDecayRate` (a tiny constant, 1e-6) every + // tick, instantly annihilating any stored complex molecules instead + // of decaying them at a per-second rate. + float retain = Math.max(0f, 1f - getDecayRate() * delta); + mass *= retain; + energy *= retain; + float molRetain = Math.max(0f, + 1f - Environment.settings.cell.complexMoleculeDecayRate.get() * delta); + complexMoleculeMasses.replaceAll((m, v) -> v * molRetain); } } diff --git a/core/src/com/protoevo/biology/cells/Cell.java b/core/src/com/protoevo/biology/cells/Cell.java index d4fdf0d..f8dcd5b 100644 --- a/core/src/com/protoevo/biology/cells/Cell.java +++ b/core/src/com/protoevo/biology/cells/Cell.java @@ -29,6 +29,14 @@ public abstract class Cell implements Serializable, Coloured, Spawnable { private Particle particle; private Environment environment; + // Phylogeny: each cell carries a lineage tag inherited from its parent + // (or assigned new at initial spawn for "founder" lineages). Combined + // with parentId, we can build a full ancestry tree without snapshotting + // every cell — most lineages die out within a few generations, only a + // few "successful" founders accumulate descendants. Default 0 means + // "uninitialised"; assigned on first creation through Environment. + private long lineageId = 0L; + private long parentId = 0L; private final Colour healthyColour = new Colour(Color.WHITE); private final Colour fullyDegradedColour = new Colour(Color.WHITE); private final Colour currentColour = new Colour(); @@ -60,6 +68,16 @@ public abstract class Cell implements Serializable, Coloured, Spawnable { private Cell engulfer = null; private boolean fullyEngulfed = false; private float joiningCheckCounter = 0f; + // Signature-match efficiency at engulf time, in (0, 1]. Receptor sets + // this on its prey when it engulfs; eat() multiplies extraction by it + // so a poorly-matched receptor digests prey slower than a perfect one. + // Default 1.0 means "no signature gate" (e.g. meat with no genome). + private float engulfEfficiency = 1f; + + public float getEngulfEfficiency() { return engulfEfficiency; } + public void setEngulfEfficiency(float e) { + this.engulfEfficiency = Math.max(0.01f, Math.min(1f, e)); + } public void update(float delta) { if (particle.isDead()) { @@ -203,11 +221,49 @@ public void setThermalConductance(float t) { public void decayResources(float delta) { foodToDigest.values().forEach(food -> food.decay(delta)); - depleteEnergy(delta * Environment.settings.cell.energyDecayRate.get()); + // Energy decay = baseRate × (radius/minR) × energyAvailable. + // Three properties: + // 1. Proportional to stored energy → no "park at the ceiling and live forever" + // exploit. A full cell burns through stored energy at the rate's natural + // half-life (~55s at default settings); decay slows automatically as the + // cell drains, so you don't starve cells faster than they can recover. + // 2. Proportional to cell size → basal metabolism. Bigger cells (which can + // reproduce) pay more to maintain. Creates a real fitness trade-off + // between reproductive size and survival-while-fasting. + // 3. Previous flat-rate formula meant the default 0.025 J/sec was a literal + // 0.025 J per second regardless of stored energy or size, so a 1200-J cell + // took 48,000 sim-seconds to drain. Sitting still was essentially free, + // which is exactly the exploit evolution discovered ("low Cilia Thrust, + // live forever"). + float baseRate = Environment.settings.cell.energyDecayRate.get(); + float radiusFactor = (float) (radius / Environment.settings.minParticleRadius.get()); + depleteEnergy(delta * baseRate * radiusFactor * energyAvailable); for (ComplexMolecule molecule : availableComplexMolecules.keySet()) { depleteComplexMolecule(molecule, delta * Environment.settings.cell.complexMoleculeDecayRate.get()); } + + applyStarvationDamage(delta); + } + + /** + * Damage health when energy is below the starvation threshold. Without + * this, the energyDecayRate lever (the homeostat's main starvation knob) + * has no death pathway — cells just stop growing at 0 energy and live + * forever, so the population could only drop via spike damage or void + * deaths. Adding this gives the homeostat real authority and makes + * "food is scarce" a survivable challenge rather than a free pass. + */ + private void applyStarvationDamage(float delta) { + float cap = getAvailableEnergyCap(); + if (cap <= 0f) + return; + float threshold = Environment.settings.cell.starvationThresholdFraction.get() * cap; + if (energyAvailable >= threshold) + return; + float deficit = 1f - (energyAvailable / threshold); // 0..1 + float rate = Environment.settings.cell.starvationDeathRate.get(); + damage(delta * deficit * rate, CauseOfDeath.STARVATION); } public void voidDamage(float delta) { @@ -342,15 +398,23 @@ public void eat(Cell engulfed, float extraction) { engulfed.removeMass(removeMultiplier * extractedMass, CauseOfDeath.EATEN); Food food; - if (foodToDigest.containsKey(foodType)) + if (foodToDigest.containsKey(foodType)) { food = foodToDigest.get(foodType); - else { + food.addSimpleMass(extractedMass); + } else { + // Constructor already sets mass=extractedMass; previously this branch + // then ALSO called addSimpleMass(extractedMass) below, doubling the + // mass of every fresh food chunk. food = new Food(extractedMass, foodType); foodToDigest.put(foodType, food); } - food.addSimpleMass(extractedMass); - food.addEnergy(engulfed.getEnergyAvailable() * extraction); + // Energy must be transferred, not duplicated: deplete the victim's + // stockpile by the same amount we hand to the food chunk. Without this, + // `cell A energy -> meat -> cell B energy` was a free copy. + float energyTransferred = engulfed.getEnergyAvailable() * extraction; + engulfed.depleteEnergy(energyTransferred); + food.addEnergy(energyTransferred); for (ComplexMolecule molecule : engulfed.getComplexMolecules()) { if (engulfed.getComplexMoleculeAvailable(molecule) > 0) { @@ -358,15 +422,31 @@ public void eat(Cell engulfed, float extraction) { engulfed.depleteComplexMolecule(molecule, extractedAmount); if (extractedAmount <= 1e-12) continue; - food.addComplexMoleculeMass(molecule, extractedMass); + // Was: food.addComplexMoleculeMass(molecule, extractedMass) + // — credited the food chunk with the prey's ENTIRE body mass + // once per molecule. That created arbitrarily many "molecules" + // each weighted by total prey mass, breaking mass conservation + // and inflating digest yields by N×bodyMass per molecule type. + food.addComplexMoleculeMass(molecule, extractedAmount); } } } public void addFood(Food.Type foodType, float amount) { - Food food = foodToDigest.getOrDefault(foodType, new Food(amount, foodType)); - food.addSimpleMass(amount); - foodToDigest.put(foodType, food); + // Same double-mass bug as the old Cell.eat had: fresh Food gets + // mass=amount via the constructor, then addSimpleMass(amount) ran + // unconditionally — every chemical-drip absorption was actually 2× + // the intended yield. Combined with chemical drip being an unsourced + // mass spring (plants don't lose mass when their chemicals are + // extracted), this let large protozoa populations sustain on tiny + // plant counts. + Food food = foodToDigest.get(foodType); + if (food == null) { + food = new Food(amount, foodType); + foodToDigest.put(foodType, food); + } else { + food.addSimpleMass(amount); + } } public void digest(float delta) { @@ -586,10 +666,22 @@ public double getRadiusDouble() { } public float getKineticEnergyRequiredForThrust(Vector2 thrustVector) { - float speed = getSpeed(); float mass = getMass(); - // This is surely not correct - return .5f * mass * (speed * speed - thrustVector.len2() / (mass * mass)); + if (mass <= 0f) return 0f; + // Minimum kinetic energy required to deliver this impulse: I²/(2m). + // Equivalent to the KE a stationary cell would gain from the impulse; + // real cost is at least this much (more if v·I > 0). + // + // The OLD formula was 0.5·m·(v² − I²/m²) and was tagged with the + // comment "This is surely not correct". For slow cells (v small, + // |I/m| moderate) the second term dominated and the function + // returned a NEGATIVE value. generateMovement then did + // depleteEnergy(work) which became energy *gain* — every flagellar + // stroke FILLED the energy pool. That's the long-standing "cells + // generate infinite energy by sitting still and flailing" exploit. + // Switching to I²/(2m) makes movement always cost energy, which is + // what every other game-balance assumption in the sim was built on. + return 0.5f * thrustVector.len2() / mass; } public void generateMovement(Vector2 thrustVector) { @@ -670,6 +762,9 @@ public Statistics getStats() { stats.putBoolean("Being Engulfed", engulfer != null); + if (lineageId != 0L) + stats.putCount("Lineage ID", (int) lineageId); + stats.putPercentage("Light Level", 100f * getLightAtCell()); stats.putTemperature("Temperature (Internal)", temperature); stats.putTemperature("Temperature (External)", getExternalTemperature()); @@ -724,6 +819,12 @@ public boolean isDead() { } public void kill(CauseOfDeath causeOfDeath) { + // Bump the per-cause death counter so we can see in the homeostat + // log what's actually killing cells (hyperthermia? starvation? + // being eaten?). Only ticks once per cell, even if kill() is + // called multiple times — particle.isDead() guards re-entry. + if (environment != null && !particle.isDead()) + environment.recordDeath(this, causeOfDeath); for (Long joiningId : particle.getJoiningIds().values()) { requestJointRemoval(joiningId); } @@ -770,6 +871,11 @@ public void setGeneration(int generation) { this.generation = generation; } + public long getLineageId() { return lineageId; } + public void setLineageId(long id) { this.lineageId = id; } + public long getParentId() { return parentId; } + public void setParentId(long id) { this.parentId = id; } + public int burstMultiplier() { return 1; } @@ -849,7 +955,22 @@ public float getConstructionMassCap() { if (particle == null) return 1f; - return 2 * particle.getMassDensity() * Geometry.getCircleArea(getRadius() * 0.25f); + // Old formula was 2 × density × area(r × 0.25) — only ~12.5% of the + // cell's own physical mass (~1 mg at min radius, ~9 mg at max). That + // was too tight: a single digested plant delivers more than the cap + // in a fraction of a sim-second, so most digested mass got clamped + // away. Worse, MoleculeProductionOrganelle + SurfaceNode attachment + // construction drain mass every tick, so anything that DID land in + // the pool got siphoned before growth could spend it. End result: + // users saw Construction Mass = 0 in stats and cells that never grew + // despite eating constantly. + // + // New formula: cap = 2 × cell's own physical mass. Big enough that a + // digestion burst can actually buffer in the pool, growth can spend + // against the buffer over multiple ticks, and side-consumers + // (organelles, surface-node attachments) don't strip-mine the pool + // to 0 before grow() runs the next tick. + return 2f * (float) particle.getMassIfRadius(getRadius()); } public void setAvailableConstructionMass(float mass) { @@ -908,10 +1029,27 @@ public float getBaseMass() { * @param mass mass to remove */ public void removeMass(float mass, CauseOfDeath causeOfDeath) { - float percentRemoved = mass / getMass(); + float totalMass = getMass(); + if (totalMass <= 0f) { + // Defensive: avoid NaN propagation into health. A fully-depleted + // shell of a cell would otherwise compute NaN for percentRemoved, + // poison the health field, and become un-killable (NaN compares + // false against everything). Just kill it directly. + kill(causeOfDeath); + return; + } + float percentRemoved = mass / totalMass; damage(percentRemoved, causeOfDeath); - double newR = (1 - percentRemoved) * getRadius(); + // Area-conserving shrink. Old formula: newR = (1 - p) × r — that + // treats r as if it were proportional to mass, but for a disc + // mass ∝ r², so removing fraction p of mass should give + // new_area = (1-p)·area → newR = r·√(1-p), not r·(1-p). + // Old code shrunk the prey ~2× too fast in radius, which made plants + // die from a few bites before the predator could finish digesting — + // each tick of eat() also removeMass'd the plant, the plant hit + // minRadius and died, and the predator lost the rest of the meal. + double newR = getRadius() * Math.sqrt(Math.max(0f, 1f - percentRemoved)); if (newR < Environment.settings.minParticleRadius.get() * 0.5f) kill(causeOfDeath); diff --git a/core/src/com/protoevo/biology/cells/PlantCell.java b/core/src/com/protoevo/biology/cells/PlantCell.java index d54e354..e995d0a 100644 --- a/core/src/com/protoevo/biology/cells/PlantCell.java +++ b/core/src/com/protoevo/biology/cells/PlantCell.java @@ -1,9 +1,15 @@ package com.protoevo.biology.cells; import com.badlogic.gdx.math.MathUtils; +import com.badlogic.gdx.math.Vector2; import com.protoevo.biology.CauseOfDeath; +import com.protoevo.biology.evolution.AminoAcidSequence; import com.protoevo.biology.evolution.Evolvable; import com.protoevo.biology.evolution.EvolvableFloat; +import com.protoevo.biology.evolution.EvolvableList; +import com.protoevo.biology.evolution.EvolvableObject; +import com.protoevo.biology.nodes.SurfaceNode; +import com.protoevo.biology.organelles.Organelle; import com.protoevo.core.Statistics; import com.protoevo.env.Environment; import com.protoevo.maths.Functions; @@ -15,6 +21,8 @@ import com.protoevo.maths.Geometry; import com.protoevo.utils.SerializableFunction; +import java.util.ArrayList; +import java.util.List; import java.util.Optional; import java.util.function.Consumer; @@ -23,6 +31,9 @@ public class PlantCell extends EvolvableCell { private float maxRadius; private float photosynthesisRate = 0; + private List surfaceNodes = new ArrayList<>(); + // Surface signature recognized by phagocytic receptors. Evolves point-by-point. + private AminoAcidSequence surfaceSignature; private static final Statistics.ComplexUnit photosynthesisUnit = new Statistics.ComplexUnit(Statistics.BaseUnit.ENERGY).divide(Statistics.BaseUnit.TIME); private final PlantSplitFn splitFn = new PlantSplitFn(this); @@ -31,6 +42,11 @@ public PlantCell(float radius, Environment environment) { super(); setRadius(Math.max(radius, Environment.settings.plant.minBirthRadius.get())); setEnvironmentAndBuildPhysics(environment); + // Low angular damping so a grazer bumping a spike-bearing plant + // actually spins it — the spike then sweeps through nearby cells + // instead of being permanently locked to its spawn orientation. + // Selection can then evolve spike-angle/cell-rotation strategies. + getParticle().setAngularDamping(0.3f); setGrowthRate(MathUtils.random(minGrowthRate(), maxGrowthRate())); maxRadius = randomMaxRadius(); @@ -97,9 +113,20 @@ public PlantCell(Environment environment) { this(randomPlantRadius(), environment); } - private boolean shouldSplit() { - return hasNotBurst() && getRadius() >= 0.99f * maxRadius && - getHealth() > Environment.settings.plant.minHealthToSplit.get(); + private boolean shouldSplit(float delta) { + // Hard gates first. + if (!hasNotBurst()) + return false; + if (getRadius() < 0.99f * maxRadius) + return false; + if (getHealth() <= Environment.settings.plant.minHealthToSplit.get()) + return false; + // Probabilistic split: a mature plant lingers as adult, splitting + // at a rate controlled by `plant.splitRate` (driven by the plant + // PID). Without this, every plant that ripened on the same tick + // burst on the same tick — that's the divide-then-die churn. + float rate = Environment.settings.plant.splitRate.get(); + return com.protoevo.core.Simulation.RANDOM.nextFloat() < rate * delta; } @Override @@ -112,6 +139,88 @@ public float maxGrowthRate() { return Environment.settings.plant.maxPlantGrowth.get(); } + // Plants now grow surface nodes too — same evolvable mechanism as protozoa, + // but we cap them at fewer initial slots since most attachments (flagellum, + // adhesion, phagocytic mouth) don't make sense on a plant. The pool gives + // evolution room to find spikes (defensive — punish grazers) and + // photoreceptors (let plants react to light beyond the simple + // photosynthesisRate calculation). + @EvolvableList( + name = "Surface Nodes", + elementClassPath = "com.protoevo.biology.nodes.SurfaceNode", + minSize = 0, + initialSize = 2 + ) + public void setSurfaceNodes(ArrayList surfaceNodes) { + this.surfaceNodes = surfaceNodes; + for (SurfaceNode node : surfaceNodes) + node.setCell(this); + } + + // Plants need organelles too — without them the cell has no + // ComplexMolecule pool, so SurfaceNode.handleAttachmentConstructionProjects + // has nothing to iterate and attachments never finish construction. + // initialSize=1 keeps plants cheaper than protozoa (which get 2). + @EvolvableList( + name = "Organelles", + elementClassPath = "com.protoevo.biology.organelles.Organelle", + initialSize = 1 + ) + public void setOrganelles(ArrayList organelles) { + for (Organelle organelle : organelles) { + organelle.setCell(this); + addOrganelle(organelle); + } + } + + @Override + public List getSurfaceNodes() { + return surfaceNodes; + } + + // Plant surface signature: 50-residue evolvable string that the + // phagocytic receptors on grazers have to match in order to engulf. + // Mutates per-character so a lineage drifts gradually instead of + // re-rolling, giving predators a hill to climb instead of a + // lottery ticket. See AminoAcidSequence / PlantSignatureTrait. + @EvolvableObject(name = "Surface Signature", + traitClass = "com.protoevo.biology.evolution.PlantSignatureTrait") + public void setSurfaceSignature(AminoAcidSequence sig) { + this.surfaceSignature = sig; + } + + public AminoAcidSequence getSurfaceSignature() { + return surfaceSignature; + } + + // Plants don't translate. If evolution wires a Flagellum onto a plant, + // the construction cost is paid but the thrust is dropped — natural + // selection should weed out flagella from the plant gene pool quickly. + // They CAN rotate though, controlled by the Rotate GRN output below — + // letting them aim their spikes and photoreceptors at threats. + @Override + public void generateMovement(Vector2 thrustVector) { + // no-op (no thrust) + } + + @Override + public float generateMovement(Vector2 thrustVector, float torque) { + return 0f; + } + + // Active rotation control. The GRN outputs a signed [-1,1] signal that + // we apply as torque each update — positive = counterclockwise. Combined + // with the low angular damping we set in setEnvironmentAndBuildPhysics, + // this lets plants actually swivel toward attackers given a working + // photoreceptor or contact sensor. Transient because it's just the + // last-tick NN output, regenerated on every gene-expression pass. + private transient float rotateSignal = 0f; + + @com.protoevo.biology.evolution.ControlVariable(name="Rotate", min=-1, max=1) + public void setRotate(float r) { + this.rotateSignal = r; + } + @Override public void update(float delta) { super.update(delta); @@ -138,14 +247,58 @@ public void update(float delta) { removeMass(delta * dps, CauseOfDeath.OVERCROWDING); } + // Apply the GRN-controlled rotation as Box2D torque. Tiny scale — + // plants are heavy and we don't want them spinning wildly, just + // enough that an evolved lineage with good sensors can aim its + // spike at a grazer it sees. Bounded by the angular damping (0.3) + // we set at body creation, so the rotation settles at a terminal + // angular velocity instead of accelerating without limit. + if (rotateSignal != 0f) { + float maxTorque = Environment.settings.plant.maxRotationTorque.get(); + getParticle().applyTorque(rotateSignal * maxTorque); + } + + // Tick surface nodes so they can call tryCreate() once and progress + // attachment construction projects every frame after that. Without + // this, plants got SurfaceNode instances from the @EvolvableList + // setter but nodeExists stayed false forever — the gene-expression + // pipeline finished and then nobody ever drove the node lifecycle. + if (surfaceNodes != null && !surfaceNodes.isEmpty()) { + for (int i = 0; i < surfaceNodes.size(); i++) { + SurfaceNode node = surfaceNodes.get(i); + node.setIndex(i); + node.update(delta); + } + // Spikes and photoreceptors need a sensor radius to find targets. + getParticle().setRangedInteractionRadius(getInteractionRange()); + } + handleAttachments(); - if (shouldSplit() && getEnv().isPresent()) { + if (shouldSplit(delta) && getEnv().isPresent()) { Environment env = getEnv().get(); env.requestBurst(PlantCell.this, PlantCell.class, splitFn); } } + // Plants need a Box2D sensor fixture so spike/photoreceptor attachments + // can pick up surrounding cells. The sensor is created once at physics + // setup; the radius is updated each frame from getInteractionRange(). + @Override + public boolean isRangedInteractionEnabled() { + return true; + } + + @Override + public float getInteractionRange() { + if (surfaceNodes == null || surfaceNodes.isEmpty()) + return 0f; + float maxRange = 0f; + for (SurfaceNode node : surfaceNodes) + maxRange = Math.max(maxRange, node.getInteractionRange()); + return maxRange; + } + @Override public float getExpressionInterval() { return Environment.settings.plant.geneExpressionInterval.get(); diff --git a/core/src/com/protoevo/biology/cells/Protozoan.java b/core/src/com/protoevo/biology/cells/Protozoan.java index 30327b8..4403d25 100644 --- a/core/src/com/protoevo/biology/cells/Protozoan.java +++ b/core/src/com/protoevo/biology/cells/Protozoan.java @@ -6,6 +6,7 @@ import com.protoevo.biology.nn.NeuralNetwork; import com.protoevo.biology.nodes.*; import com.protoevo.biology.organelles.Organelle; +import com.protoevo.core.Simulation; import com.protoevo.core.Statistics; import com.protoevo.env.ChemicalSolution; import com.protoevo.env.Environment; @@ -26,7 +27,44 @@ public class Protozoan extends EvolvableCell private GeneExpressionFunction crossOverGenome; private float matingCooldown = 0; - private boolean mateDesire, splitDesire = false; + // Continuous NN signals in [0,1]. Earlier these were thresholded to a + // boolean at 0.5, which collapsed selection: a cell with signal 0.51 + // behaved identically to one at 1.0, and 0.49 identically to 0.0. That + // made it impossible for evolution to find smooth strategies like + // "split cautiously when food is plentiful" or "mate rarely". With + // continuous signals + per-frame probability, mating/splitting frequency + // scales linearly with the output, so the gradient is preserved. + // Transient: the GRN re-populates these every expression tick, so old + // saves can omit them without losing meaningful state. + private transient float mateDesireSignal = 0f; + private transient float splitDesireSignal = 0f; + // Multi-layered memory with NN-controlled write gates and read-only + // product slots. Three temporal layers (Fast/Med/Slow), each with + // 2 slots and per-slot blend rate α. On write, the slot updates: + // + // gate = clamp(setMemoryGate_*, 0, 1) // NN-controlled + // eff_α = α · gate // gate scales blend rate + // slot = (1−eff_α)·slot + eff_α·input + // + // gate=0 means "don't write this tick" — turns the slot into a + // proper latch that remembers specific events instead of always + // smoothing recent inputs. That's the LSTM input-gate pattern. + // + // In addition to the 6 directly-writable slots, the NN reads two + // PRODUCT slots that are multiplicative compositions of pairs of + // regular slots. Additive NNs can't synthesize "A AND B"-type + // features without specifically-tuned activations; exposing the + // product gives evolution access to conjunctive sensing for free. + // + // All transient; the GRN refills them every tick. + private static final int MEMORY_SLOTS = 6; + private static final float[] MEMORY_ALPHA = { + 0.7f, 0.7f, // Fast 0, 1 + 0.3f, 0.3f, // Medium 0, 1 + 0.05f, 0.05f // Slow 0, 1 + }; + private transient float[] memory; + private transient float[] memoryGate; // NN-controlled write gate per slot private List surfaceNodes; private float damageRate = 0; @@ -82,11 +120,18 @@ public void update(float delta) for (Cell engulfedCell : engulfedCells) { handleEngulfing(engulfedCell, delta); - eat(engulfedCell, Environment.settings.protozoa.engulfEatingRateMultiplier.get() * delta); + // Multiply by the receptor's signature-match efficiency that + // was stamped onto the prey at engulf time. Perfect match + // digests at full rate; a barely-passing match digests at + // ~5% (floored). This is what makes a well-matched receptor + // actually pay off in the arms race. + float rate = Environment.settings.protozoa.engulfEatingRateMultiplier.get() + * delta * engulfedCell.getEngulfEfficiency(); + eat(engulfedCell, rate); } engulfedCells.removeIf(this::removeEngulfedCondition); - if (shouldSplit() && hasNotBurst() && getEnv().isPresent()) { + if (shouldSplit(delta) && hasNotBurst() && getEnv().isPresent()) { Environment e = getEnv().get(); e.requestBurst(Protozoan.this, Protozoan.class, createChild); } @@ -98,19 +143,31 @@ public void update(float delta) } private void handleMating(float delta) { - if (Environment.settings.protozoa.matingEnabled.get() && mateDesire && matingCooldown <= 0) { - for (Collision contact : getParticle().getContacts()) { - Object other = contact.getOther(contact); - if (other instanceof Protozoan) { - Protozoan protozoan = (Protozoan) other; - if (protozoan.mateDesire) { - setMate(protozoan); - return; - } + if (matingCooldown > 0) { + matingCooldown -= delta; + return; + } + if (!Environment.settings.protozoa.matingEnabled.get()) + return; + + // Probabilistic per-frame check on a continuous signal — at signal=1 + // the cell tries to mate ~MATE_BASE_RATE times per second on average, + // at signal=0 never. This preserves selection gradients (small + // signal → rare mating) instead of a binary threshold that snaps + // between full-rate and never. Partner consent uses the same scale. + final float MATE_BASE_RATE = 2f; + if (Simulation.RANDOM.nextFloat() >= mateDesireSignal * delta * MATE_BASE_RATE) + return; + + for (Collision contact : getParticle().getContacts()) { + Object other = contact.getOther(contact); + if (other instanceof Protozoan) { + Protozoan partner = (Protozoan) other; + if (Simulation.RANDOM.nextFloat() < partner.mateDesireSignal) { + setMate(partner); + return; } } - } else { - matingCooldown -= delta; } } @@ -171,6 +228,121 @@ public void setColour(Colour colour) { setDegradedColour(degradeColour(colour, 0.3f)); } + // Three cell-level evolvable sequences, all applied to every phagocytic + // receptor this cell builds (so the cell as a whole has one "diet"). + // + // plantReceptorKey (50 chars): matched against PlantCell.surfaceSignature. + // Higher identity = faster plant digestion. + // + // protozoaReceivingReceptor (75 chars): cell's IDENTITY. What predators + // target when they try to eat this cell. + // + // protozoaPhagocyticReceptor (75 chars): what THIS cell looks for in + // prey protozoa. Matched against the prey's protozoaReceivingReceptor. + // + // Predation is ASYMMETRIC. A eats B iff A.phagocytic matches B.receiving. + // B eats A iff B.phagocytic matches A.receiving. The two are independent + // — predator status is directional. + // + // Critically: for a cell NOT to eat its own kin, the lineage must keep + // its own phagocytic ≠ its own receiving. A cell whose phag ≈ recv would + // match its siblings as prey AND be matched by its siblings as prey — + // the lineage cannibalizes itself and goes extinct. Selection thus + // drives phag ≠ recv. Net effect: lineages diverge into "I am X / + // I hunt Y" specializations where X and Y are different sequences. + private AminoAcidSequence plantReceptorKey; + private AminoAcidSequence protozoaReceivingReceptor; + private AminoAcidSequence protozoaPhagocyticReceptor; + + @EvolvableObject(name="Plant Receptor Key", + traitClass="com.protoevo.biology.evolution.PlantSignatureTrait") + public void setPlantReceptorKey(AminoAcidSequence key) { + this.plantReceptorKey = key; + } + + @EvolvableObject(name="Receiving Receptor", + traitClass="com.protoevo.biology.evolution.ProtozoaSignatureTrait") + public void setProtozoaReceivingReceptor(AminoAcidSequence key) { + this.protozoaReceivingReceptor = key; + } + + @EvolvableObject(name="Phagocytic Receptor", + traitClass="com.protoevo.biology.evolution.ProtozoaSignatureTrait") + public void setProtozoaPhagocyticReceptor(AminoAcidSequence key) { + this.protozoaPhagocyticReceptor = key; + } + + public AminoAcidSequence getPlantReceptorKey() { return plantReceptorKey; } + public AminoAcidSequence getProtozoaReceivingReceptor() { return protozoaReceivingReceptor; } + public AminoAcidSequence getProtozoaPhagocyticReceptor() { return protozoaPhagocyticReceptor; } + + // Contact-only signature-match sensors. The NN can only know about + // compatibility with a neighbour it's PHYSICALLY TOUCHING — same as + // real cells, which sense membrane proteins via direct contact rather + // than at a distance. Returns 0 if not in contact with anything of + // the relevant type. Evolved NNs can use these to: + // - approach/stay near a high-match plant (more food per second) + // - engulf-or-not when touching another protozoa (Prey Match) + // - flee/retract when touching a predator (Predator Threat) + + @GeneRegulator(name="Touch Plant Match", min=0, max=1) + public float getTouchPlantMatch() { + if (plantReceptorKey == null || getParticle() == null) return 0f; + float best = 0f; + for (Collision c : getParticle().getContacts()) { + Object o = c.getOther(getParticle()); + if (!(o instanceof com.protoevo.physics.Particle)) continue; + Object u = ((com.protoevo.physics.Particle) o).getUserData(); + if (u instanceof PlantCell) { + AminoAcidSequence sig = ((PlantCell) u).getSurfaceSignature(); + if (sig != null) { + float m = plantReceptorKey.identityWith(sig); + if (m > best) best = m; + } + } + } + return best; + } + + @GeneRegulator(name="Touch Prey Match", min=0, max=1) + public float getTouchPreyMatch() { + if (protozoaPhagocyticReceptor == null || getParticle() == null) return 0f; + float best = 0f; + for (Collision c : getParticle().getContacts()) { + Object o = c.getOther(getParticle()); + if (!(o instanceof com.protoevo.physics.Particle)) continue; + Object u = ((com.protoevo.physics.Particle) o).getUserData(); + if (u instanceof Protozoan && u != this) { + AminoAcidSequence sig = ((Protozoan) u).getProtozoaReceivingReceptor(); + if (sig != null) { + float m = protozoaPhagocyticReceptor.identityWith(sig); + if (m > best) best = m; + } + } + } + return best; + } + + @GeneRegulator(name="Touch Predator Threat", min=0, max=1) + public float getTouchPredatorThreat() { + if (protozoaReceivingReceptor == null || getParticle() == null) return 0f; + float best = 0f; + for (Collision c : getParticle().getContacts()) { + Object o = c.getOther(getParticle()); + if (!(o instanceof com.protoevo.physics.Particle)) continue; + Object u = ((com.protoevo.physics.Particle) o).getUserData(); + if (u instanceof Protozoan && u != this) { + AminoAcidSequence theirPhag = + ((Protozoan) u).getProtozoaPhagocyticReceptor(); + if (theirPhag != null) { + float m = theirPhag.identityWith(protozoaReceivingReceptor); + if (m > best) best = m; + } + } + } + return best; + } + @GeneRegulator(name="Plant to Digest") public float getPlantToDigest() { if (!getFoodToDigest().containsKey(Food.Type.Plant)) @@ -194,12 +366,39 @@ public float getPlantGradient() { if (solution == null) return 0; + // Sample well outside the cell's own footprint, otherwise the readings + // are dominated by chemicals the cell itself just consumed — that's + // the "surrounded by plants but gradient = 0 or negative" case. + // Normalize by the sum so the *direction* signal stays meaningful + // when the field is densely saturated front and back. Vector2 pos = getPos(); - tmp.set(pos).add(dir.setLength(1.1f * getRadius())); - float plantGradientAhead = solution.getPlantDensity(tmp); - tmp.set(pos).sub(dir.setLength(1.1f * getRadius())); - float plantGradientBehind = solution.getPlantDensity(tmp); - return plantGradientAhead - plantGradientBehind; + float sampleR = 4f * getRadius(); + tmp.set(pos).add(dir.setLength(sampleR)); + float plantAhead = solution.getPlantDensity(tmp); + tmp.set(pos).sub(dir.setLength(sampleR)); + float plantBehind = solution.getPlantDensity(tmp); + float sum = plantAhead + plantBehind; + if (sum <= 1e-6f) return 0f; + return (plantAhead - plantBehind) / sum; + } + + @GeneRegulator(name="Plant Density Local", min=0, max=1) + public float getPlantDensityLocal() { + Optional env = getEnv(); + if (!env.isPresent()) + return 0; + // Direct spatial-hash count of plant CELLS in the same chunk as us. + // We deliberately do NOT use the chemical field here: chemicals at + // the cell's own position get consumed by the cell's own extraction + // pass, so a protozoan sitting on plants reads ~0 chemical — that's + // what made the original "Plant Density" signal misleading. + // Counting actual plant entities is the correct "I'm in a food + // cluster" signal: it reflects ground truth, not a depleted field. + Environment e = env.get(); + int count = e.getLocalCount(PlantCell.class, getPos()); + int cap = Math.max(1, e.getLocalCapacity(PlantCell.class)); + float density = (float) count / (float) cap; + return density > 1f ? 1f : density; } @ControlVariable(name="Cilia Thrust", min=0, max=1) @@ -213,26 +412,136 @@ public void setCiliaTurn(float turn) { this.thrustTurn = Environment.settings.protozoa.maxCiliaTurn.get() * turn; } - @GeneRegulator(name="Orientation", min=0, max=1) - public float getOrientation() { - return (float) (thrustAngle % (2 * Math.PI)) / (2 * (float) Math.PI); + // The old getOrientation was broken — Java's `%` returns a negative + // result for negative operands, so thrustAngle % 2π for any + // negative-rotation lineage returned values outside [0,1]. Also: a + // single-channel circular value with a discontinuity at the wrap point + // is hostile to NN training — the NN sees angle 359° and 1° as far + // apart even though they're adjacent. The standard fix is to split + // the angle into sin/cos components, both bounded in [-1,1] with no + // discontinuity. + @GeneRegulator(name="Heading Sin", min=-1, max=1) + public float getHeadingSin() { + return (float) Math.sin(thrustAngle); + } + @GeneRegulator(name="Heading Cos", min=-1, max=1) + public float getHeadingCos() { + return (float) Math.cos(thrustAngle); } + // getProtozoaSpeed was advertised as min=0,max=1 but `getSpeed()/getRadius()` + // can easily exceed 1 (a small fast cell). Clamp into the advertised + // range so the NN input isn't silently saturated and lying about the + // dynamic range available to it. @GeneRegulator(name="Speed", min=0, max=1) public float getProtozoaSpeed() { - return getSpeed() / getRadius(); + float r = getRadius(); + if (r <= 1e-9f) + return 0f; + float s = getSpeed() / r; + return s > 1f ? 1f : s; } @ControlVariable(name="Mate Desire", min=0, max=1) public void setMateDesire(float mate) { - this.mateDesire = mate > 0.5f; + this.mateDesireSignal = mate; } @ControlVariable(name="Split Desire", min=0, max=1) public void setSplitDesire(float split) { - this.splitDesire = split > 0.5f; + this.splitDesireSignal = split; } + // ===== Memory state ===== + private float[] mem() { + if (memory == null) memory = new float[MEMORY_SLOTS]; + return memory; + } + private float[] memGates() { + if (memoryGate == null) { + memoryGate = new float[MEMORY_SLOTS]; + // Default to 1.0 (always-write) so a freshly-spawned cell with + // no GRN influence yet still behaves like the old un-gated + // memory until evolution wires the gates up. + for (int i = 0; i < MEMORY_SLOTS; i++) memoryGate[i] = 1f; + } + return memoryGate; + } + /** Gated blend: slot = (1 - α·gate)·slot + α·gate·input. + * When gate ≈ 0 the slot doesn't update this tick (true latch); + * when gate ≈ 1 it blends at the layer's base α. */ + private void writeMemory(int slot, float input) { + float[] m = mem(); + float gate = memGates()[slot]; + if (gate < 0f) gate = 0f; + else if (gate > 1f) gate = 1f; + float a = MEMORY_ALPHA[slot] * gate; + m[slot] = (1f - a) * m[slot] + a * input; + } + + // --- Direct slot writes (value the NN wants to commit if its gate fires) --- + @ControlVariable(name="Memory Fast 0", min=-1, max=1) + public void setMemoryFast0(float v) { writeMemory(0, v); } + @ControlVariable(name="Memory Fast 1", min=-1, max=1) + public void setMemoryFast1(float v) { writeMemory(1, v); } + @ControlVariable(name="Memory Med 0", min=-1, max=1) + public void setMemoryMed0(float v) { writeMemory(2, v); } + @ControlVariable(name="Memory Med 1", min=-1, max=1) + public void setMemoryMed1(float v) { writeMemory(3, v); } + @ControlVariable(name="Memory Slow 0", min=-1, max=1) + public void setMemorySlow0(float v) { writeMemory(4, v); } + @ControlVariable(name="Memory Slow 1", min=-1, max=1) + public void setMemorySlow1(float v) { writeMemory(5, v); } + + // --- Per-slot write gates (LSTM-style "should I write this tick?") --- + @ControlVariable(name="Gate Fast 0", min=0, max=1) + public void setGateFast0(float v) { memGates()[0] = v; } + @ControlVariable(name="Gate Fast 1", min=0, max=1) + public void setGateFast1(float v) { memGates()[1] = v; } + @ControlVariable(name="Gate Med 0", min=0, max=1) + public void setGateMed0(float v) { memGates()[2] = v; } + @ControlVariable(name="Gate Med 1", min=0, max=1) + public void setGateMed1(float v) { memGates()[3] = v; } + @ControlVariable(name="Gate Slow 0", min=0, max=1) + public void setGateSlow0(float v) { memGates()[4] = v; } + @ControlVariable(name="Gate Slow 1", min=0, max=1) + public void setGateSlow1(float v) { memGates()[5] = v; } + + // --- Direct slot reads --- + @GeneRegulator(name="Memory Fast 0", min=-1, max=1) + public float getMemoryFast0() { return mem()[0]; } + @GeneRegulator(name="Memory Fast 1", min=-1, max=1) + public float getMemoryFast1() { return mem()[1]; } + @GeneRegulator(name="Memory Med 0", min=-1, max=1) + public float getMemoryMed0() { return mem()[2]; } + @GeneRegulator(name="Memory Med 1", min=-1, max=1) + public float getMemoryMed1() { return mem()[3]; } + @GeneRegulator(name="Memory Slow 0", min=-1, max=1) + public float getMemorySlow0() { return mem()[4]; } + @GeneRegulator(name="Memory Slow 1", min=-1, max=1) + public float getMemorySlow1() { return mem()[5]; } + + // --- Read-only product slots: the multiplicative composition of two + // memory slots from different temporal layers. Additive NNs can't + // synthesize "A AND B"-type conjunctive features without + // specifically-tuned activations on hidden neurons; exposing + // the product gives evolution that operation directly. + // Range is [-1, 1] × [-1, 1] = [-1, 1] so the regulator mapping + // is correct without renormalising. + @GeneRegulator(name="Memory Fast×Slow", min=-1, max=1) + public float getMemoryFastSlow() { return mem()[0] * mem()[4]; } + @GeneRegulator(name="Memory Med×Med", min=-1, max=1) + public float getMemoryMedMed() { return mem()[2] * mem()[3]; } + + /** Read-only view of the memory state, for UI inspection. */ + public float[] getMemory() { + return mem(); + } + public float[] getMemoryGates() { + return memGates(); + } + // ===== end Memory state ===== + public void generateThrust(float delta) { if (thrustMag <= 1e-12) return; @@ -251,9 +560,18 @@ public float getMaxRadius() { return 1.05f * splitRadius; } - private boolean shouldSplit() { - return splitDesire && getRadius() >= splitRadius - && getHealth() >= Environment.settings.protozoa.minHealthToSplit.get(); + private boolean shouldSplit(float delta) { + // Hard gates: must be big enough and healthy enough. + if (getRadius() < splitRadius) + return false; + if (getHealth() < Environment.settings.protozoa.minHealthToSplit.get()) + return false; + // Probabilistic check on the continuous NN signal so split rate + // scales smoothly with desire instead of snapping at 0.5. At + // signal=1 the cell averages ~SPLIT_BASE_RATE splits per second + // (still gated by radius/health regrowth between splits). + final float SPLIT_BASE_RATE = 1.5f; + return Simulation.RANDOM.nextFloat() < splitDesireSignal * delta * SPLIT_BASE_RATE; } private Protozoan createSplitChild(float r) { @@ -429,6 +747,10 @@ public Statistics getStats() { stats.put("Herbivore Factor", herbivoreFactor); stats.putPercentage("Mean Mutation Chance", 100 * geneExpressionFunction.getMeanMutationRate()); stats.putCount("Num Mutations", geneExpressionFunction.getMutationCount()); + if (geneExpressionFunction.getGRNGenome() != null) { + stats.put("Lineage Mutation Rate ×", + geneExpressionFunction.getGRNGenome().getMutationRateMultiplier()); + } int i = 0; for (LineageTag tag : tags) { @@ -458,13 +780,46 @@ public float getGrowthRate() { } public void age(float delta) { - damageRate = getRadius() * Environment.settings.protozoa.starvationFactor.get(); - damage(damageRate * delta, CauseOfDeath.OLD_AGE); + // 1. STARVATION damage: only when actually energy-starved (below 25% + // of capacity). Without this guard the original code bled well-fed + // cells to death on starvationFactor — the "dying with a ton of + // energy" case. + float energyCap = getRadius() + * Environment.settings.cell.energyCapFactor.get(); + if (energyCap <= 0f || getEnergyAvailable() < 0.25f * energyCap) { + damageRate = getRadius() + * Environment.settings.protozoa.starvationFactor.get(); + damage(damageRate * delta, CauseOfDeath.OLD_AGE); + } + + // 2. SENESCENCE damage: age-driven, applies regardless of energy. + // Without this, a cell that maxed its radius and pinned its + // energy near the cap had ZERO death pathway — growth stopped, + // splits were blocked by health < minHealthToSplit, and the cell + // just sat there. Users observed individuals alive for 2000+ sim + // seconds doing nothing. Quadratic ramp past maxLifespan + // guarantees no immortals: at 2× lifespan damage hits peak rate, + // at 3× lifespan it's 4× peak — death is inevitable. + float maxLifespan = Environment.settings.protozoa.maxLifespan.get(); + if (maxLifespan > 0f) { + float excess = (getTimeAlive() - maxLifespan) / maxLifespan; + if (excess > 0f) { + float rate = excess * excess + * Environment.settings.protozoa.senescenceDeathRate.get(); + damage(rate * delta, CauseOfDeath.OLD_AGE); + } + } } @Override public boolean isEdible() { - return false; + // Living protozoa are now edible — but only by phagocytic receptors + // whose protozoa-key matches this cell's surface signature closely + // enough to clear the 80% identity threshold. So in practice, a + // generalist receptor with random keys can't just gobble its + // neighbours: predation requires a specifically-evolved key that + // matches the prey lineage. See PhagocyticReceptor.engulfCondition. + return !isDead(); } public boolean hasMated() { diff --git a/core/src/com/protoevo/biology/evolution/AminoAcidSequence.java b/core/src/com/protoevo/biology/evolution/AminoAcidSequence.java new file mode 100644 index 0000000..ed729a2 --- /dev/null +++ b/core/src/com/protoevo/biology/evolution/AminoAcidSequence.java @@ -0,0 +1,134 @@ +package com.protoevo.biology.evolution; + +import com.badlogic.gdx.math.MathUtils; +import com.protoevo.core.Simulation; + +import java.io.Serializable; +import java.util.Arrays; + +/** + * Fixed-length sequence of amino-acid letters used for lock-and-key surface + * recognition between phagocytic receptors and their prey. Evolves by point + * mutation; identity match (0..1) drives the engulf-rate scaling in + * PhagocyticReceptor + Cell.eat. + * + * Why a sequence and not a single float "tag": + * A single-float signature would let an entire predator population converge + * on one plant signature with a single floating-point mutation, ending the + * arms race instantly. A 24/50-character sequence has enough dimensions + * that incremental mutation has to actually search — and that search has a + * gradient (partial matches give partial digestion rate), so evolution can + * climb it instead of needing a lucky hit. Same reasoning as why real + * biological recognition uses protein epitopes and not a single tag. + */ +public final class AminoAcidSequence implements Serializable { + + public static final long serialVersionUID = 1L; + + /** Standard 20-letter amino-acid one-letter code. */ + private static final char[] ALPHABET = + "ACDEFGHIKLMNPQRSTVWY".toCharArray(); + + private char[] residues; + + /** No-arg constructor for Kryo deserialization. Kryo needs a way to + * instantiate the class via reflection before populating its fields; + * without this, loading any save that contains a Protozoan/PlantCell + * throws "Class cannot be created (missing no-arg constructor)". + * Empty array is fine — Kryo overwrites `residues` immediately. */ + private AminoAcidSequence() { + this.residues = new char[0]; + } + + public AminoAcidSequence(int length) { + this.residues = new char[length]; + for (int i = 0; i < length; i++) + residues[i] = ALPHABET[Simulation.RANDOM.nextInt(ALPHABET.length)]; + } + + public AminoAcidSequence(char[] residues) { + // Defensive copy so the caller can't mutate our backing array. + this.residues = Arrays.copyOf(residues, residues.length); + } + + public AminoAcidSequence(AminoAcidSequence other) { + this(other.residues); + } + + public int length() { + return residues.length; + } + + public char[] residues() { + return residues; + } + + /** + * Identity fraction with another sequence, in [0, 1]. + * Returns 0 if the other sequence is null or of a different length — + * different-length sequences are not comparable and should not be + * considered any kind of match. + */ + public float identityWith(AminoAcidSequence other) { + if (other == null || other.residues.length != residues.length) + return 0f; + int matches = 0; + for (int i = 0; i < residues.length; i++) { + if (residues[i] == other.residues[i]) matches++; + } + return (float) matches / residues.length; + } + + /** + * Length of the longest run of CONTIGUOUS matching residues with + * another sequence. Models the "binding domain" semantics of real + * receptor-ligand recognition: scattered identity doesn't matter, but + * a stretch of matching residues forms an actual binding interface. + * Returns 0 if other is null or length-mismatched. + */ + public int longestContiguousMatch(AminoAcidSequence other) { + if (other == null || other.residues.length != residues.length) + return 0; + int best = 0, cur = 0; + for (int i = 0; i < residues.length; i++) { + if (residues[i] == other.residues[i]) { + cur++; + if (cur > best) best = cur; + } else { + cur = 0; + } + } + return best; + } + + /** + * Return a copy with a small number of point mutations applied. Each + * position is independently flipped to a random alphabet character with + * probability {@code perResidueRate}. With the defaults this averages + * ~1 substitution per generation for the 24-mer protozoan signature + * and ~2 for the 50-mer plant signature — slow enough that lineages + * preserve their identity across generations, fast enough that an + * arms-race opponent can keep up. + */ + public AminoAcidSequence mutated(float perResidueRate) { + char[] out = Arrays.copyOf(residues, residues.length); + for (int i = 0; i < out.length; i++) { + if (MathUtils.random() < perResidueRate) { + char prev = out[i]; + // Pick a different letter so mutations actually change + // something (otherwise ~5% of mutations would be no-ops). + char next; + do { + next = ALPHABET[Simulation.RANDOM.nextInt(ALPHABET.length)]; + } while (next == prev); + out[i] = next; + } + } + return new AminoAcidSequence(out); + } + + @Override + public String toString() { + return new String(residues); + } +} diff --git a/core/src/com/protoevo/biology/evolution/FloatTrait.java b/core/src/com/protoevo/biology/evolution/FloatTrait.java index 9176bb9..8a2650f 100644 --- a/core/src/com/protoevo/biology/evolution/FloatTrait.java +++ b/core/src/com/protoevo/biology/evolution/FloatTrait.java @@ -1,6 +1,7 @@ package com.protoevo.biology.evolution; import com.badlogic.gdx.math.MathUtils; +import com.protoevo.core.Simulation; import java.io.Serializable; import java.util.Map; @@ -85,6 +86,45 @@ public Trait createNew(Float value) { return new FloatTrait(this, value); } + /** + * Override the default {@link Trait#cloneWithMutation()} (which fully + * re-rolls every value to a uniform random) with NEAT-style perturbation: + * 90% of mutations are small Gaussian nudges from the existing value, + * 10% are full re-rolls. Same rationale as in SynapseGene — the previous + * "always re-roll" made it impossible to fine-tune signature-driven traits + * (e.g. SurfaceNode.constructionSignature, which decides whether a node + * becomes an eye, mouth, flagellum, etc.). Every mutation was a coin flip + * across the whole trait range, so populations couldn't converge on + * "this signature builds a photoreceptor" — eyes evolve reliably with + * perturbation. + */ + @Override + public Trait cloneWithMutation() { + if (Math.random() > getMutationRate()) + return copy(); + + if (Simulation.RANDOM.nextBoolean()) + mutateMutationRate(); + + float current = value; + float mutated; + if (Math.random() < 0.9) { + float range = maxValue - minValue; + // sigma = 10% of the trait range; small enough to fine-tune, + // large enough to escape local optima over generations. + float sigma = 0.1f * (range > 0f ? range : 1f); + mutated = current + (float) (Simulation.RANDOM.nextGaussian() * sigma); + } else { + mutated = newRandomValue(); + } + if (mutated < minValue) mutated = minValue; + else if (mutated > maxValue) mutated = maxValue; + + Trait newTrait = createNew(mutated); + newTrait.incrementMutationCount(); + return newTrait; + } + @Override public String getTraitName() { return traitName; diff --git a/core/src/com/protoevo/biology/evolution/GRNFactory.java b/core/src/com/protoevo/biology/evolution/GRNFactory.java index 024b7dc..59d84ce 100644 --- a/core/src/com/protoevo/biology/evolution/GRNFactory.java +++ b/core/src/com/protoevo/biology/evolution/GRNFactory.java @@ -230,6 +230,48 @@ public static NetworkGenome createNetworkGenome(GeneExpressionFunction geneExpre networkGenome.mutate(); } + // Seed basic foraging reflexes. Two signals: + // - "Plant Density Local" → "Cilia Thrust": swim whenever you smell + // food. This keeps cells moving inside dense plant clusters where + // the gradient is ~0. + // - "Plant Gradient" → "Cilia Turn": when food is behind you the + // gradient is negative; with negative weight that becomes positive + // turn output, so the cell rotates until plants are ahead. + // Wrapped in try/catch in case a non-protozoan genome (surface-node + // sub-genomes etc.) ever flows through here with a different shape. + try { + seedReflexSynapse(networkGenome, "Plant Density Local", "Cilia Thrust:Output", 2.0f); + seedReflexSynapse(networkGenome, "Plant Gradient", "Cilia Turn:Output", -1.0f); + } catch (Throwable t) { + System.err.println("[seed] failed: " + t); + } + return networkGenome; } + + private static void seedReflexSynapse(NetworkGenome g, String inLabel, + String outLabel, float weight) { + com.protoevo.biology.nn.NeuronGene in = g.getNeuronGene(inLabel); + com.protoevo.biology.nn.NeuronGene out = g.getNeuronGene(outLabel); + // For non-protozoan genomes (plants, surface-node sub-genomes, etc.) + // these labels don't exist. Bail silently. + if (in == null || out == null) return; + + // Strict-edit version: only nudge an existing synapse if there is one. + // We deliberately don't add a new synapse — appending was suspected of + // interacting badly with downstream phenotype building during world + // gen, so this strictly mutates state already present. + for (com.protoevo.biology.nn.SynapseGene s : g.getSynapseGenes()) { + if (s.getIn() != null && s.getOut() != null + && s.getIn().getId() == in.getId() + && s.getOut().getId() == out.getId()) { + s.setWeight(weight); + s.setDisabled(false); + return; + } + } + // No existing synapse → silently do nothing. The 50% initial + // connectivity will mean some protozoa get the seed and some don't, + // which is fine — the gene pool will spread it. + } } diff --git a/core/src/com/protoevo/biology/evolution/GeneExpressionFunction.java b/core/src/com/protoevo/biology/evolution/GeneExpressionFunction.java index fcb43d7..0f758dc 100644 --- a/core/src/com/protoevo/biology/evolution/GeneExpressionFunction.java +++ b/core/src/com/protoevo/biology/evolution/GeneExpressionFunction.java @@ -393,7 +393,6 @@ public void tick() { return; setGRNInputs(); geneRegulatoryNetwork.tick(); - setGRNInputs(); } public void update() { diff --git a/core/src/com/protoevo/biology/evolution/PlantSignatureTrait.java b/core/src/com/protoevo/biology/evolution/PlantSignatureTrait.java new file mode 100644 index 0000000..1ecdfc0 --- /dev/null +++ b/core/src/com/protoevo/biology/evolution/PlantSignatureTrait.java @@ -0,0 +1,34 @@ +package com.protoevo.biology.evolution; + +/** + * 50-residue surface signature carried by plants and matched by the + * "plant key" on each PhagocyticReceptor. Used for the relatively loose + * plant-recognition arms race: a 50% identity match is the threshold to + * engulf at all, perfect match gives full engulf rate. + */ +public class PlantSignatureTrait extends SignatureTrait { + + public static final long serialVersionUID = 1L; + + public static final int LENGTH = 50; + + public PlantSignatureTrait() {} + + public PlantSignatureTrait(String geneName) { + super(geneName); + } + + public PlantSignatureTrait(PlantSignatureTrait other, AminoAcidSequence value) { + super(other, value); + } + + @Override + protected int sequenceLength() { + return LENGTH; + } + + @Override + public Trait createNew(AminoAcidSequence value) { + return new PlantSignatureTrait(this, new AminoAcidSequence(value)); + } +} diff --git a/core/src/com/protoevo/biology/evolution/ProtozoaSignatureTrait.java b/core/src/com/protoevo/biology/evolution/ProtozoaSignatureTrait.java new file mode 100644 index 0000000..543bbd8 --- /dev/null +++ b/core/src/com/protoevo/biology/evolution/ProtozoaSignatureTrait.java @@ -0,0 +1,57 @@ +package com.protoevo.biology.evolution; + +/** + * 24-residue surface signature carried by protozoa and matched by the + * "protozoa key" on each PhagocyticReceptor. Predation is meant to be + * harder than grazing, so this is shorter (smaller search space) AND + * the match threshold is stricter (80% vs 50%). + * + * Combined effect: a predator lineage has to closely track a prey + * lineage's evolving signature to keep eating it. Prey can escape + * predation by drifting their signature faster than predators can + * follow — a Red Queen race in 24 dimensions. + */ +public class ProtozoaSignatureTrait extends SignatureTrait { + + public static final long serialVersionUID = 1L; + + // 75-residue sequence. Used for BOTH the receiving receptor (identity) + // and the phagocytic receptor (target-seeking) on a Protozoan — same + // trait class, two independent fields, so each evolves on its own + // trajectory. The asymmetric A.phag-vs-B.recv match rule then creates + // selection pressure for a lineage to keep its OWN phag ≠ recv, which + // prevents kin cannibalism (since same-lineage A and B have + // A.phag ≈ B.recv only if a lineage's own phag ≈ recv). + public static final int LENGTH = 75; + + public ProtozoaSignatureTrait() {} + + public ProtozoaSignatureTrait(String geneName) { + super(geneName); + } + + public ProtozoaSignatureTrait(ProtozoaSignatureTrait other, AminoAcidSequence value) { + super(other, value); + } + + @Override + protected int sequenceLength() { + return LENGTH; + } + + /** + * Lower per-residue rate (0.025) because the sequence is now 75 + * long: expected ~1.9 substitutions per generation. Faster than that + * and lineages diverge so quickly that co-evolutionary tracking + * (predator following prey) can never catch up. + */ + @Override + protected float perResidueMutationRate() { + return 0.025f; + } + + @Override + public Trait createNew(AminoAcidSequence value) { + return new ProtozoaSignatureTrait(this, new AminoAcidSequence(value)); + } +} diff --git a/core/src/com/protoevo/biology/evolution/SignatureTrait.java b/core/src/com/protoevo/biology/evolution/SignatureTrait.java new file mode 100644 index 0000000..2f577e3 --- /dev/null +++ b/core/src/com/protoevo/biology/evolution/SignatureTrait.java @@ -0,0 +1,107 @@ +package com.protoevo.biology.evolution; + +import java.io.Serializable; +import java.util.Map; + +/** + * Base evolvable trait wrapping an {@link AminoAcidSequence}. Concrete + * subclasses fix the sequence length (e.g. {@link PlantSignatureTrait} is + * 50, {@link ProtozoaSignatureTrait} is 24). Mutation is per-character + * point substitution rather than full re-roll, so an arms-race opponent + * can hill-climb on a partial match instead of needing a lucky full hit. + */ +public abstract class SignatureTrait + implements Trait, Serializable { + + public static final long serialVersionUID = 1L; + + private AminoAcidSequence value; + private String geneName; + private float mutationRate; + private int mutationCount; + + protected SignatureTrait() {} + + protected SignatureTrait(String geneName) { + this.geneName = geneName; + this.value = newRandomValue(); + } + + protected SignatureTrait(SignatureTrait other, AminoAcidSequence value) { + this.geneName = other.geneName; + this.mutationRate = other.mutationRate; + this.mutationCount = other.mutationCount; + this.value = value; + } + + /** Subclasses fix the sequence length (plant vs protozoa signature). */ + protected abstract int sequenceLength(); + + /** + * Per-residue mutation rate. With length 50 and rate 0.04 we expect ~2 + * substitutions per mutation event; with length 24 and rate 0.04 we + * expect ~1. Subclasses can override if they want a different cadence. + */ + protected float perResidueMutationRate() { + return 0.04f; + } + + @Override + public AminoAcidSequence getValue(Map dependencies) { + return value; + } + + @Override + public AminoAcidSequence newRandomValue() { + return new AminoAcidSequence(sequenceLength()); + } + + /** + * Override the default Trait.cloneWithMutation (which would fully + * re-roll the sequence and destroy any partial-match adaptation the + * lineage had accumulated). Instead apply point mutations to the + * existing residues so the sequence drifts continuously and + * descendants stay recognizably similar to the parent. + */ + @Override + public Trait cloneWithMutation() { + if (Math.random() > getMutationRate()) + return copy(); + AminoAcidSequence mutated = value == null + ? newRandomValue() + : value.mutated(perResidueMutationRate()); + Trait child = createNew(mutated); + child.incrementMutationCount(); + return child; + } + + @Override + public void setMutationRate(float rate) { + this.mutationRate = rate; + } + + @Override + public float getMutationRate() { + return mutationRate; + } + + @Override + public int getMutationCount() { + return mutationCount; + } + + @Override + public void incrementMutationCount() { + mutationCount++; + } + + @Override + public String valueString() { + return value == null ? "" : value.toString(); + } + + @Override + public String getTraitName() { + return geneName; + } +} diff --git a/core/src/com/protoevo/biology/nn/NetworkGenome.java b/core/src/com/protoevo/biology/nn/NetworkGenome.java index 1a0c9eb..a348af3 100644 --- a/core/src/com/protoevo/biology/nn/NetworkGenome.java +++ b/core/src/com/protoevo/biology/nn/NetworkGenome.java @@ -22,8 +22,15 @@ public class NetworkGenome implements Serializable @JsonIgnore private Random random = Simulation.RANDOM; private int numStructuralMutations = 0, nSensors, nOutputs; - private static int maxSynapseMutationsPerGeneration = 10; - private static int maxNodeMutationsPerGeneration = 10; + // Per-lineage mutation rate multiplier. The global node/synapse budgets + // in EvolutionSettings act as a baseline; each genome scales them by + // this factor. Itself mutates each generation via a bounded log-Gaussian + // step, so lineages can evolve their own pace of exploration. Some + // settle low (stable adaptations, slow drift) and others go high + // (fast-exploring but risk losing locked-in good circuits). + private float mutationRateMultiplier = 1f; + private static final float MIN_MUTATION_RATE_MULT = 0.25f; + private static final float MAX_MUTATION_RATE_MULT = 4f; public NetworkGenome(NetworkGenome other) { setProperties(other); @@ -44,6 +51,7 @@ public void setProperties(NetworkGenome other) numStructuralMutations = other.numStructuralMutations; nSensors = other.nSensors; nOutputs = other.nOutputs; + mutationRateMultiplier = other.mutationRateMultiplier; } private NeuronGene[] copy(NeuronGene[] neuronGenes) { @@ -224,14 +232,18 @@ private int getNeuronGeneIndex(NeuronGene gene) { } public NeuronGene getNeuronGene(String name) { + // Some neurons (e.g. ones created by the no-label NetworkGenome + // constructor at line 71-75 used by plants/non-protozoa cells) have + // null labels. Use name.equals(label) so a null on the genome side + // returns false instead of NPE'ing. for (NeuronGene n : sensorNeuronGenes) - if (n.getLabel().equals(name)) + if (name.equals(n.getLabel())) return n; for (NeuronGene n : outputNeuronGenes) - if (n.getLabel().equals(name)) + if (name.equals(n.getLabel())) return n; for (NeuronGene n : hiddenNeuronGenes) - if (n.getLabel().equals(name)) + if (name.equals(n.getLabel())) return n; return null; } @@ -337,7 +349,12 @@ else if (Math.random() < synapseGenes[idx].getMutationRate()) public void mutate() { - for (int i = 0; i < maxNodeMutationsPerGeneration; i++) { + int baseNode = Environment.settings.evo.nodeMutationsPerGeneration.get(); + int baseSyn = Environment.settings.evo.synapseMutationsPerGeneration.get(); + int nodeBudget = Math.max(1, Math.round(baseNode * mutationRateMultiplier)); + int synapseBudget = Math.max(1, Math.round(baseSyn * mutationRateMultiplier)); + + for (int i = 0; i < nodeBudget; i++) { int idx = MathUtils.random( 0, sensorNeuronGenes.length + outputNeuronGenes.length + hiddenNeuronGenes.length - 1); if (idx < sensorNeuronGenes.length) @@ -349,11 +366,23 @@ else if (idx < sensorNeuronGenes.length + outputNeuronGenes.length) } if (synapseGenes.length > 0) - for (int i = 0; i < maxSynapseMutationsPerGeneration; i++) { + for (int i = 0; i < synapseBudget; i++) { int idx = MathUtils.random(0, synapseGenes.length - 1); mutateSynapseGene(idx); } + + // Drift the mutation rate itself. Log-Gaussian step (multiplicative) + // so increments and decrements are symmetric in log-space; bounded + // to prevent runaway in either direction. + float step = (float) Math.exp(0.15 * Simulation.RANDOM.nextGaussian()); + mutationRateMultiplier *= step; + if (mutationRateMultiplier < MIN_MUTATION_RATE_MULT) + mutationRateMultiplier = MIN_MUTATION_RATE_MULT; + if (mutationRateMultiplier > MAX_MUTATION_RATE_MULT) + mutationRateMultiplier = MAX_MUTATION_RATE_MULT; } + + public float getMutationRateMultiplier() { return mutationRateMultiplier; } public NetworkGenome crossover(NetworkGenome other) { @@ -494,11 +523,59 @@ public NeuralNetwork phenotype() return new NeuralNetwork(neurons); } + /** + * Standard NEAT compatibility distance: + * d = c1*E/N + c2*D/N + c3*Wbar + * where E = excess genes (innovations past the other genome's max), + * D = disjoint genes (non-matching innovations within range), + * Wbar = mean absolute weight difference of matching genes, + * N = max(|genes|, 1) for normalization. + * Used by the genetic-clusters view to group similar protozoa. + */ public float distance(NetworkGenome other) { -// int excess = 0; -// int disjoint = 0; - return 0; + if (other == null) return Float.POSITIVE_INFINITY; + final float c1 = 1.0f, c2 = 1.0f, c3 = 0.4f; + + SynapseGene[] a = this.synapseGenes; + SynapseGene[] b = other.synapseGenes; + if (a == null) a = new SynapseGene[0]; + if (b == null) b = new SynapseGene[0]; + + int maxInnovA = -1, maxInnovB = -1; + java.util.HashMap mapA = new java.util.HashMap<>(); + java.util.HashMap mapB = new java.util.HashMap<>(); + for (SynapseGene g : a) { + mapA.put(g.getInnovation(), g); + if (g.getInnovation() > maxInnovA) maxInnovA = g.getInnovation(); + } + for (SynapseGene g : b) { + mapB.put(g.getInnovation(), g); + if (g.getInnovation() > maxInnovB) maxInnovB = g.getInnovation(); + } + + int excess = 0, disjoint = 0, matching = 0; + float weightDiffSum = 0f; + int innovCutoff = Math.min(maxInnovA, maxInnovB); + + java.util.HashSet allInnov = new java.util.HashSet<>(mapA.keySet()); + allInnov.addAll(mapB.keySet()); + for (int innov : allInnov) { + boolean inA = mapA.containsKey(innov); + boolean inB = mapB.containsKey(innov); + if (inA && inB) { + matching++; + weightDiffSum += Math.abs(mapA.get(innov).getWeight() - mapB.get(innov).getWeight()); + } else if (innov > innovCutoff) { + excess++; + } else { + disjoint++; + } + } + + int n = Math.max(Math.max(a.length, b.length), 1); + float wbar = matching > 0 ? weightDiffSum / matching : 0f; + return c1 * excess / n + c2 * disjoint / n + c3 * wbar; } public String toString() @@ -547,14 +624,14 @@ public int numberOfSensors() { public boolean hasSensor(String label) { for (NeuronGene gene : sensorNeuronGenes) - if (gene.getLabel().equals(label)) + if (label.equals(gene.getLabel())) return true; return false; } public boolean hasOutput(String label) { for (NeuronGene gene : outputNeuronGenes) - if (gene.getLabel().equals(label)) + if (label.equals(gene.getLabel())) return true; return false; } diff --git a/core/src/com/protoevo/biology/nn/Neuron.java b/core/src/com/protoevo/biology/nn/Neuron.java index ce84b0f..ccf730f 100644 --- a/core/src/com/protoevo/biology/nn/Neuron.java +++ b/core/src/com/protoevo/biology/nn/Neuron.java @@ -192,7 +192,7 @@ public void setGraphicsPosition(Vector2 pos) { } public boolean hasGraphicsPosition() { - return graphicsPos == null; + return graphicsPos != null; } public float getGraphicsX() { diff --git a/core/src/com/protoevo/biology/nn/SynapseGene.java b/core/src/com/protoevo/biology/nn/SynapseGene.java index 89e438b..44ae32d 100644 --- a/core/src/com/protoevo/biology/nn/SynapseGene.java +++ b/core/src/com/protoevo/biology/nn/SynapseGene.java @@ -97,7 +97,19 @@ public SynapseGene cloneWithMutation() { nMutations++; - newGene.weight = randomInitialWeight(); + // Standard NEAT-style weight mutation: ~90% perturb the existing weight + // by a small Gaussian, ~10% fully randomize. The original code always + // re-rolled, which makes fine-tuning impossible — every mutation was a + // discontinuous jump, so the population could never lock in good weights. + if (Math.random() < 0.9) { + float perturbation = (float) (Simulation.RANDOM.nextGaussian() * 0.15); + newGene.weight = newGene.weight + perturbation; + // Keep magnitude bounded so a runaway random walk can't blow up. + if (newGene.weight > 4f) newGene.weight = 4f; + else if (newGene.weight < -4f) newGene.weight = -4f; + } else { + newGene.weight = randomInitialWeight(); + } if (Simulation.RANDOM.nextBoolean()) { newGene.mutationRate = MathUtils.random(mutationRateMin, mutationRateMax); diff --git a/core/src/com/protoevo/biology/nodes/Flagellum.java b/core/src/com/protoevo/biology/nodes/Flagellum.java index b378c00..711671b 100644 --- a/core/src/com/protoevo/biology/nodes/Flagellum.java +++ b/core/src/com/protoevo/biology/nodes/Flagellum.java @@ -76,6 +76,11 @@ public void update(float delta, float[] input, float[] output) { if (lastCellPos.isZero() || cell.getRadius() == 0) { lastCellPos.set(currentCellPos); + // Clear output[0] too. Previously the non-3D branch never wrote + // it on the first tick, so the GRN read whatever stale value + // was in the buffer (could be another node's last write or the + // value from the previous attachment that lived at this index). + output[0] = 0; if (io3D) { output[1] = 0; output[2] = 0; diff --git a/core/src/com/protoevo/biology/nodes/PhagocyticReceptor.java b/core/src/com/protoevo/biology/nodes/PhagocyticReceptor.java index 32a3bbb..22e1536 100644 --- a/core/src/com/protoevo/biology/nodes/PhagocyticReceptor.java +++ b/core/src/com/protoevo/biology/nodes/PhagocyticReceptor.java @@ -3,6 +3,7 @@ import com.badlogic.gdx.math.Vector2; import com.protoevo.biology.CauseOfDeath; import com.protoevo.biology.cells.*; +import com.protoevo.biology.evolution.AminoAcidSequence; import com.protoevo.core.Statistics; import com.protoevo.env.Environment; import com.protoevo.physics.Collision; @@ -79,8 +80,14 @@ public boolean getShouldEngulfMeat() { private void handleDim3IO(float[] input, float[] output) { Cell cell = node.getCell(); - engulfPlant = input[0] > 0f; - engulfMeat = input[1] > 0f; + // Default-engulf semantics: a cell that built a phagocytic receptor + // engulfs unless the GRN actively *suppresses* it (input < -0.25). + // The previous threshold `input > 0` meant random/noisy weights + // produced ~50% engulf rate, and combined with conservative NEAT + // perturbation many cells never crossed it — they had a working + // receptor and starved next to plants. + engulfPlant = input[0] > -0.25f; + engulfMeat = input[1] > -0.25f; if (!((Protozoan) cell).getEngulfedCells().contains(lastEngulfed)) lastEngulfed = null; @@ -95,7 +102,8 @@ else if (lastEngulfed instanceof MeatCell) private void handleDim1IO(float[] input, float[] output) { Cell cell = node.getCell(); - engulfPlant = input[0] > 0f; + // Same default-engulf semantics as the dim-3 path above. + engulfPlant = input[0] > -0.25f; engulfMeat = engulfPlant; if (!((Protozoan) cell).getEngulfedCells().contains(lastEngulfed)) @@ -105,14 +113,109 @@ private void handleDim1IO(float[] input, float[] output) { } } + // CONTIGUOUS-RUN engulf gate: receptor must share a stretch of at least + // this many consecutive identical residues with the prey signature. + // Scattered identity doesn't count — this models a real binding domain + // where the receptor pocket needs a contiguous epitope to grip. + // + // Thresholds live in CellSettings (plantEngulfMinRun / protozoaEngulfMinRun) + // and are ratcheted up monotonically by the homeostat when the population + // is stable — so the gate starts forgiving and tightens only as lineages + // prove they can handle it. The previous hard-coded 10/15 was too strict + // at the start of a run: fresh sequences rarely cleared it, no engulfs + // happened, no population sustained, and the existing + // selectionPressureExponent ratchet couldn't fire either. Reading from + // settings lets both ratchets co-evolve at the same pace. + public boolean engulfCondition(Cell other) { if (other instanceof PlantCell && !engulfPlant) return false; if (other instanceof MeatCell && !engulfMeat) return false; - return other.isEdible() + if (node.getCell().isAttachedTo(other)) + return false; + if (!(other.isEdible() && correctSizes(other) && notEngulfed(other) - && closeEnough(other) && roomFor(other); + && closeEnough(other) && roomFor(other))) + return false; + // Meat carries no genome; standard checks above are sufficient. + if (other instanceof MeatCell) return true; + // Plant/protozoa engulf needs a CONTIGUOUS run of matching residues + // long enough to count as a real binding region. + return longestRun(other) >= engulfMinRunFor(other); + } + + /** + * Identity fraction between this cell's appropriate key and the prey's + * signature/identity, in [0, 1]. Used downstream for digestion efficiency + * scaling. Meat returns 1.0 (no signature applies); missing keys/sigs + * return 0.0 so callers can short-circuit cleanly. + */ + public float signatureMatch(Cell other) { + if (other instanceof MeatCell) return 1f; + Cell self = node.getCell(); + if (!(self instanceof Protozoan)) return 0f; + Protozoan me = (Protozoan) self; + + if (other instanceof PlantCell) { + AminoAcidSequence key = me.getPlantReceptorKey(); + AminoAcidSequence sig = ((PlantCell) other).getSurfaceSignature(); + return key == null || sig == null ? 0f : key.identityWith(sig); + } + if (other instanceof Protozoan) { + AminoAcidSequence key = me.getProtozoaPhagocyticReceptor(); + AminoAcidSequence sig = ((Protozoan) other).getProtozoaReceivingReceptor(); + return key == null || sig == null ? 0f : key.identityWith(sig); + } + return 0f; + } + + /** + * Longest contiguous run of matching residues between the receptor key + * and the prey identity. Different code path from signatureMatch + * because we use this for the binary engulf GATE; signatureMatch is + * still used for digest-rate scaling. + */ + public int longestRun(Cell other) { + Cell self = node.getCell(); + if (!(self instanceof Protozoan)) return 0; + Protozoan me = (Protozoan) self; + + if (other instanceof PlantCell) { + AminoAcidSequence key = me.getPlantReceptorKey(); + AminoAcidSequence sig = ((PlantCell) other).getSurfaceSignature(); + return key == null || sig == null ? 0 : key.longestContiguousMatch(sig); + } + if (other instanceof Protozoan) { + AminoAcidSequence key = me.getProtozoaPhagocyticReceptor(); + AminoAcidSequence sig = ((Protozoan) other).getProtozoaReceivingReceptor(); + return key == null || sig == null ? 0 : key.longestContiguousMatch(sig); + } + return 0; + } + + // Hard floor on protozoa-on-protozoa engulf — kin-cannibalism must + // ALWAYS require a substantial co-evolved binding region, regardless + // of any setting value (including those loaded from older saves where + // the default was lower). + // + // 24 of 75 (32% of sequence) means the chance of a random pair + // matching by coincidence is ~ 52 × (1/20)^24 ≈ 5e-31 — completely + // impossible. Even strong evolutionary pressure takes hundreds of + // generations to align two sequences this much. Cannibalism is now a + // late-game evolutionary achievement, not something cells can stumble + // into in the first few sim-seconds. This is the user-requested + // "always have some level of continuance" — and a high level at that. + private static final int PROTOZOA_MIN_RUN_FLOOR = 24; + + private int engulfMinRunFor(Cell other) { + if (other instanceof PlantCell) + return Environment.settings.cell.plantEngulfMinRun.get(); + if (other instanceof Protozoan) + return Math.max( + PROTOZOA_MIN_RUN_FLOOR, + Environment.settings.cell.protozoaEngulfMinRun.get()); + return 0; } private boolean roomFor(Cell other) { @@ -126,8 +229,18 @@ private boolean roomFor(Cell other) { private boolean correctSizes(Cell other) { Cell cell = node.getCell(); float progressFactor = 0.5f + 0.5f * getConstructionProgress(); - return other.getRadius() < progressFactor * cell.getRadius() * 0.8f - && cell.getRadius() > 2 * Environment.settings.minParticleRadius.get(); + // Was: prey radius < 0.8 × cell radius × progressFactor. That meant + // prey had to be substantially smaller than the predator — a cell + // touching a plant the same size as itself couldn't eat it. Bumped + // to 1.2× so cells can engulf prey up to ~20% larger; combined with + // roomFor's area gate this still prevents nonsensical "minnow eats + // whale" scenarios. + // Also dropped the 2×minRadius lower bound on the predator: with + // the default-engulf semantics there's no reason to forbid small + // cells from trying — they'll fail roomFor naturally if the prey + // doesn't fit. + return other.getRadius() < progressFactor * cell.getRadius() * 1.2f + && cell.getRadius() > Environment.settings.minParticleRadius.get(); } private boolean notEngulfed(Cell other) { @@ -135,9 +248,20 @@ private boolean notEngulfed(Cell other) { } private boolean closeEnough(Cell other) { + // Was: distance from THIS NODE'S world position. That meant a cell + // touching prey on its left side wouldn't engulf if its receptor + // node was angled toward the right — even though physically in + // contact. Now we accept either: the node is close enough OR the + // cells are physically in contact (centers within sum of radii + + // engulfRange). This matches the user-visible expectation: "if my + // cell is touching food, it should eat." + Cell cell = node.getCell(); + float r = engulfRange(); Vector2 nodePos = node.getWorldPosition(); - float d = other.getRadius() + engulfRange(); - return nodePos.dst2(other.getPos()) < d*d; + float dNode = other.getRadius() + r; + if (nodePos.dst2(other.getPos()) < dNode*dNode) return true; + float dCenter = cell.getRadius() + other.getRadius() + r * 0.5f; + return cell.getPos().dst2(other.getPos()) < dCenter*dCenter; } public float engulfRange() { @@ -146,11 +270,50 @@ public float engulfRange() { public void engulf(Cell cell) { lastEngulfed = cell; + // Digestion efficiency = bindingStrength^(1 + selectionPressureExponent). + // bindingStrength = longestRun / sequenceLength, so it captures + // both "how big is the binding region?" (numerator) normalized + // against the sequence we're matching against (denominator). + // A 50-residue full-match pair has bindingStrength = 1.0; a 10-of-50 + // contiguous run = 0.2 (and would be at the minimum engulf gate). + // + // The exponent is dynamic, ratcheted upward by the homeostat (see + // Simulation.maybeRatchetSelection). Pressure=0 means efficiency is + // linear in bindingStrength (gentle). Pressure=3 means it's quartic + // (harsh — only near-perfect binding gives full rate). + // + // Floor 0.02 keeps meat (no signature, returns 1.0) workable. + float strength = bindingStrength(cell); + float exponent = 1f + Environment.settings.cell.selectionPressureExponent.get(); + // Floor is the dynamically-ratcheted engulfBaseEfficiency, not a + // hardcoded 0.02. When base is 1.0 (game start) eff is always 1.0 + // and the receptor system is effectively bypassed. As the homeostat + // ratchets base down toward 0.02, signature match progressively + // matters more — low-match engulfs digest at the floor rate, high + // matches at strength^exponent. Cells whose signatures don't track + // plant drift gradually lose efficiency to the ratchet. + float floor = Environment.settings.cell.engulfBaseEfficiency.get(); + float eff = Math.max(floor, (float) Math.pow(strength, exponent)); + cell.setEngulfEfficiency(eff); cell.setEngulfer(node.getCell()); cell.kill(CauseOfDeath.EATEN); ((Protozoan) node.getCell()).getEngulfedCells().add(cell); } + /** Binding strength in [0, 1] — longest contiguous match normalized + * by sequence length. Meat (no genome) returns 1.0. */ + public float bindingStrength(Cell other) { + if (other instanceof MeatCell) return 1f; + if (other instanceof PlantCell || other instanceof Protozoan) { + int run = longestRun(other); + int len = (other instanceof PlantCell) + ? com.protoevo.biology.evolution.PlantSignatureTrait.LENGTH + : com.protoevo.biology.evolution.ProtozoaSignatureTrait.LENGTH; + return len <= 0 ? 0f : (float) run / (float) len; + } + return 0f; + } + @Override public String getName() { return "Phagocytic Receptor"; diff --git a/core/src/com/protoevo/biology/nodes/Spike.java b/core/src/com/protoevo/biology/nodes/Spike.java index 26fa164..a7b9f99 100644 --- a/core/src/com/protoevo/biology/nodes/Spike.java +++ b/core/src/com/protoevo/biology/nodes/Spike.java @@ -18,7 +18,23 @@ public class Spike extends NodeAttachment implements Serializable { private static final long serialVersionUID = 1L; private Vector2 spikePoint = new Vector2(); - private final float attackFactor = 10f; + // Reused scratch vectors so Spike.update doesn't allocate two Vector2 + // per (spike × contact) every frame. With dense melee the GC churn + // showed up as noticeable jitter in earlier profiling. Transient + + // lazy-init: making them transient avoids breaking older saves whose + // Spike objects didn't have these fields, and the lazy check keeps it + // safe after deserialisation. + private transient Vector2 tmpDir; + private transient Vector2 tmpStart; + // 10 → 2. dps = attackFactor × (attack - defense). With attackFactor=10 + // a typical attack-defense gap of 3-5 gave dps=30-50, meaning a cell + // in contact lost 0.03-0.05 health/tick → death in ~20-30 ticks. At + // 256× time dilation that's microseconds of real time — the user saw + // it as "instant kill". 2 brings dps to 6-10, so combat takes ~10 + // sim-seconds to kill rather than 0.02 — slow enough that prey can + // try to flee, defenders have time to take effect, and the + // SPIKE_DAMAGE death cause stops dominating early-game logs. + private final float attackFactor = 2f; private float lastDPS = 0; private float extension = 1; private float myLastAttack = 0, theirLastDefense = 0; @@ -51,15 +67,44 @@ public void update(float delta, float[] input, float[] output) { if (cell == null) return; - extension = Functions.clampedLinearRemap(input[0], -1, 1, 0, 1); + // Spikes must be ACTIVELY extended to deal damage. Default-state + // signal (input≈0 from random/un-evolved GRN weights) must give + // extension≈0. Earlier `clamp(input[0], 0, 1)` still allowed an + // input of 0.3 → 30% extension → meaningful damage; plants with + // default GRN signal kept passively killing grazers at a slower + // rate. Require input > 0.5 before any meaningful extension: this + // is the "aggression threshold" the cell's NN must actively + // cross — random weights almost never sustain it, but evolved + // combat lineages can. + extension = Functions.clampedLinearRemap(input[0], 0.5f, 1f, 0f, 1f); spikePoint = getSpikePoint(); for (Object toInteract : cell.getInteractionQueue()) { if (toInteract instanceof Particle && (((Particle) toInteract).getUserData() instanceof Cell)) { Cell other = (Cell) ((Particle) toInteract).getUserData(); - Vector2 dir = spikePoint.cpy().sub(node.getWorldPosition()); - Vector2 start = node.getWorldPosition().cpy().sub(other.getPos()); - float[] ts = Geometry.circleIntersectLineTs(dir, start, other.getRadius()); + // Plants don't spike other plants. Plants are stationary + // neighbours that often touch — without this guard, any + // plant that evolved spikes would damage every adjacent + // plant, including would-be mutualists for adhesion. The + // user explicitly didn't want plant-on-plant friendly fire. + // Protozoa-on-protozoa spikes ARE still allowed (predation + // / combat), and plant→protozoa defensive damage works as + // before. + if (cell instanceof com.protoevo.biology.cells.PlantCell + && other instanceof com.protoevo.biology.cells.PlantCell) + continue; + // Don't spike adhered partners — without this, any cluster + // that evolved adhesion + spikes would tear itself apart, so + // multicellular defensive structures couldn't emerge. Cells + // don't recognize bound friends; this rule lets them. + if (cell.isAttachedTo(other)) + continue; + if (tmpDir == null) tmpDir = new Vector2(); + if (tmpStart == null) tmpStart = new Vector2(); + Vector2 nodePos = node.getWorldPosition(); + tmpDir.set(spikePoint).sub(nodePos); + tmpStart.set(nodePos).sub(other.getPos()); + float[] ts = Geometry.circleIntersectLineTs(tmpDir, tmpStart, other.getRadius()); if (Geometry.lineIntersectCondition(ts)) { output[0] = 1f; @@ -82,6 +127,16 @@ public void update(float delta, float[] input, float[] output) { float dps = attackFactor * (myLastAttack - theirLastDefense); other.damage(dps * delta, CauseOfDeath.SPIKE_DAMAGE); lastDPS = dps; + // Spikes were free attacks. Charge a small basal + // energy cost so combat isn't free, but don't make + // it ruinous — earlier `dps * delta * 5f` came out + // to ~250 J/sec for a typical attack, draining the + // attacker in under 6 sim-seconds. New rate is + // ~5 J/sec sustained — feels like real metabolism + // overhead without being self-defeating. + float energyCost = dps * delta * 0.1f; + cell.depleteEnergy(energyCost); + cell.addActivity(0.1f * delta); } else { lastDPS = 0; diff --git a/core/src/com/protoevo/biology/nodes/SurfaceNode.java b/core/src/com/protoevo/biology/nodes/SurfaceNode.java index c26b717..b0356cc 100644 --- a/core/src/com/protoevo/biology/nodes/SurfaceNode.java +++ b/core/src/com/protoevo/biology/nodes/SurfaceNode.java @@ -159,6 +159,11 @@ public void setAngle(float angle) { this.angle = angle; } + // Receptor keys live on the cell (Protozoan.plantReceptorKey / + // protozoaSurfaceKey), not on individual surface nodes. Every receptor + // on the cell shares the same diet specialization — a cell has one + // "diet" rather than per-receptor specialization. + public Vector2 getRelativePos() { float t = cell.getParticle().getAngle() + angle; relativePosition.set((float) Math.cos(t), (float) Math.sin(t)).scl(cell.getRadius()); diff --git a/core/src/com/protoevo/biology/organelles/MoleculeProductionOrganelle.java b/core/src/com/protoevo/biology/organelles/MoleculeProductionOrganelle.java index 5b2f90a..8501e2c 100644 --- a/core/src/com/protoevo/biology/organelles/MoleculeProductionOrganelle.java +++ b/core/src/com/protoevo/biology/organelles/MoleculeProductionOrganelle.java @@ -52,7 +52,19 @@ public void update(float delta, float[] input) { float constructionMassAvailable = cell.getConstructionMassAvailable(); float energyAvailable = cell.getEnergyAvailable(); - if (producedMass > 0 && constructionMassAvailable >= producedMass && energyAvailable >= requiredEnergy) { + // Reserve gate: only produce molecules when the cell has surplus + // construction mass beyond a 50%-of-cap reserve. Without this gate, + // organelles strip-mine the mass pool at ~5 mg/sec while chemical + // drip (the nibble path that's supposed to bootstrap new cells) + // delivers only ~0.3 mg/sec. Result: cells survive energy-wise but + // never accumulate enough mass to grow → never split → no + // evolution toward better foraging. The reserve guarantees grow() + // and repair() get first claim on incoming mass, and organelles + // only fire when the cell is genuinely well-fed. + float reserve = 0.5f * cell.getConstructionMassCap(); + if (producedMass > 0 + && constructionMassAvailable >= reserve + producedMass + && energyAvailable >= requiredEnergy) { cell.addAvailableComplexMolecule(productionMolecule, producedMass); cell.depleteConstructionMass(producedMass); cell.depleteEnergy(requiredEnergy); diff --git a/core/src/com/protoevo/core/DominantLineageReport.java b/core/src/com/protoevo/core/DominantLineageReport.java new file mode 100644 index 0000000..0c861de --- /dev/null +++ b/core/src/com/protoevo/core/DominantLineageReport.java @@ -0,0 +1,247 @@ +package com.protoevo.core; + +import com.protoevo.biology.cells.Cell; +import com.protoevo.biology.cells.PlantCell; +import com.protoevo.biology.cells.Protozoan; +import com.protoevo.biology.evolution.AminoAcidSequence; +import com.protoevo.biology.evolution.GeneExpressionFunction; +import com.protoevo.biology.nn.NetworkGenome; +import com.protoevo.biology.nn.NeuronGene; +import com.protoevo.biology.nn.SynapseGene; +import com.protoevo.env.Environment; + +import java.io.FileWriter; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +/** + * Writes a human-readable summary of the currently-dominant protozoan + * lineage to disk on every save. The point: lets a human (or me, in a + * later session) inspect what evolution actually built without having + * to deserialize the binary env file. + * + * "Dominant" is computed by greedy NEAT speciation against a threshold — + * same algorithm as the in-game Genetic Clusters viewer. We pick the + * largest cluster, choose its representative (the first protozoan that + * seeded that cluster), and dump: + * - cluster population & % of total + * - signatures (plant key, receiving, phagocytic) + * - top-level vitals (radius, generation, health, energy) + * - GRN topology: input / output / hidden neuron counts + * - every synapse (in label, out label, weight, disabled flag) + * + * Plain text rather than JSON so the file is grep-able and the synapse + * table reads like a debug dump rather than a data blob. + */ +public final class DominantLineageReport { + + /** Same threshold as GeneticClustersScreen — keep them in sync. */ + private static final float SPECIATION_THRESHOLD = 1.5f; + /** Cap the synapse table to avoid 10k-line files on big genomes. */ + private static final int MAX_SYNAPSES_LISTED = 500; + + private DominantLineageReport() {} + + public static void write(Environment env, String path) throws IOException { + if (env == null) return; + + // Cluster both populations independently. Plant ecology and + // protozoa ecology are separate axes of selection, so we want a + // dominant for each. + List protozoa = collectCells(env, Protozoan.class); + List plants = collectCells(env, PlantCell.class); + List protoClusters = cluster(protozoa); + List plantClusters = cluster(plants); + + Cluster dominantProto = largest(protoClusters); + Cluster dominantPlant = largest(plantClusters); + + try (FileWriter w = new FileWriter(path)) { + w.write("Dominant Lineage Report\n"); + w.write("=======================\n"); + w.write(String.format("Generated: sim_time=%.1fs%n", env.getElapsedTime())); + w.write("\n"); + + w.write("===== PROTOZOA =====\n\n"); + if (dominantProto == null) + w.write("No living protozoa at save time.\n\n"); + else + writeClusterReport(w, env, dominantProto, + protozoa.size(), protoClusters.size()); + + w.write("\n"); + w.write("===== PLANTS =====\n\n"); + if (dominantPlant == null) + w.write("No living plants at save time.\n"); + else + writeClusterReport(w, env, dominantPlant, + plants.size(), plantClusters.size()); + } + } + + @SuppressWarnings("unchecked") + private static List collectCells(Environment env, Class type) { + List out = new ArrayList<>(); + for (Cell c : new ArrayList<>(env.getCells())) { + if (type.isInstance(c)) out.add(c); + } + return out; + } + + private static List cluster(List cells) { + List clusters = new ArrayList<>(); + for (Cell c : cells) { + NetworkGenome g; + try { g = ((com.protoevo.biology.cells.EvolvableCell) c).getGeneExpressionFunction().getGRNGenome(); } + catch (Throwable t) { continue; } + if (g == null) continue; + + Cluster best = null; + float bestDist = Float.POSITIVE_INFINITY; + for (Cluster cl : clusters) { + float d; + try { d = g.distance(cl.representativeGenome); } + catch (Throwable t) { continue; } + if (Float.isNaN(d)) continue; + if (d < bestDist) { bestDist = d; best = cl; } + } + if (best != null && bestDist < SPECIATION_THRESHOLD) { + best.members.add(c); + } else { + Cluster fresh = new Cluster(c, g); + fresh.members.add(c); + clusters.add(fresh); + } + } + return clusters; + } + + private static Cluster largest(List cs) { + if (cs.isEmpty()) return null; + Cluster d = cs.get(0); + for (Cluster c : cs) if (c.members.size() > d.members.size()) d = c; + return d; + } + + private static void writeClusterReport(FileWriter w, Environment env, Cluster dominant, + int totalCells, int nClusters) throws IOException { + Cell rep = dominant.representative; + NetworkGenome g = dominant.representativeGenome; + w.write(String.format("Total cells: %d%n", totalCells)); + w.write(String.format("Distinct clusters: %d (NEAT distance threshold %.2f)%n", + nClusters, SPECIATION_THRESHOLD)); + w.write(String.format("Dominant cluster: %d cells (%.1f%% of population)%n%n", + dominant.members.size(), + 100.0 * dominant.members.size() / totalCells)); + + w.write("Representative cell\n"); + w.write("-------------------\n"); + w.write(String.format(" Generation: %d%n", rep.getGeneration())); + w.write(String.format(" Radius: %.4f%n", rep.getRadius())); + w.write(String.format(" Health: %.3f%n", rep.getHealth())); + w.write(String.format(" Energy available: %.2f J%n", rep.getEnergyAvailable())); + w.write(String.format(" Time alive: %.1fs%n", rep.getTimeAlive())); + w.write(String.format(" Mutation count (NN): %d%n", g.getMutationCount())); + w.write("\n"); + + w.write("Surface signatures\n"); + w.write("------------------\n"); + if (rep instanceof Protozoan) { + Protozoan p = (Protozoan) rep; + writeSig(w, "Plant Receptor Key (50)", p.getPlantReceptorKey()); + writeSig(w, "Receiving Receptor (75)", p.getProtozoaReceivingReceptor()); + writeSig(w, "Phagocytic Receptor (75)", p.getProtozoaPhagocyticReceptor()); + } else if (rep instanceof PlantCell) { + writeSig(w, "Plant Surface Signature (50)", + ((PlantCell) rep).getSurfaceSignature()); + } + w.write("\n"); + + w.write("Evolvable trait values\n"); + w.write("----------------------\n"); + dumpTraits(w, ((com.protoevo.biology.cells.EvolvableCell) rep).getGeneExpressionFunction()); + w.write("\n"); + + w.write("Gene-regulatory network\n"); + w.write("-----------------------\n"); + int nSensors = 0, nOutputs = 0, nHidden = 0; + Iterator it = g.iterateNeuronGenes(); + while (it.hasNext()) { + NeuronGene n = it.next(); + if (n == null) continue; + switch (n.getType()) { + case SENSOR: nSensors++; break; + case OUTPUT: nOutputs++; break; + case HIDDEN: nHidden++; break; + } + } + int totalSyn = g.getSynapseGenes() == null ? 0 : g.getSynapseGenes().length; + int activeSyn = 0; + if (g.getSynapseGenes() != null) { + for (SynapseGene s : g.getSynapseGenes()) + if (!s.isDisabled()) activeSyn++; + } + w.write(String.format(" Sensors: %d%n", nSensors)); + w.write(String.format(" Outputs: %d%n", nOutputs)); + w.write(String.format(" Hidden: %d%n", nHidden)); + w.write(String.format(" Synapses: %d total, %d active, %d disabled%n%n", + totalSyn, activeSyn, totalSyn - activeSyn)); + + w.write("Synapses\n"); + w.write("--------\n"); + w.write(String.format(" %-32s -> %-32s %10s %s%n", + "in", "out", "weight", "status")); + if (g.getSynapseGenes() != null) { + int shown = 0; + for (SynapseGene s : g.getSynapseGenes()) { + if (shown >= MAX_SYNAPSES_LISTED) { + w.write(String.format(" ... %d more synapses not shown%n", + g.getSynapseGenes().length - shown)); + break; + } + String inLabel = s.getIn() == null ? "?" : nullToDash(s.getIn().getLabel()); + String outLabel = s.getOut() == null ? "?" : nullToDash(s.getOut().getLabel()); + w.write(String.format(" %-32s -> %-32s %+10.4f %s%n", + inLabel, outLabel, s.getWeight(), + s.isDisabled() ? "DISABLED" : "active")); + shown++; + } + } + } + + private static void writeSig(FileWriter w, String label, AminoAcidSequence s) throws IOException { + w.write(String.format(" %s: %s%n", label, s == null ? "(none)" : s.toString())); + } + + private static void dumpTraits(FileWriter w, GeneExpressionFunction fn) throws IOException { + if (fn == null) { w.write(" (no GeneExpressionFunction)\n"); return; } + try { + for (String name : fn.getTraitNames()) { + try { + Object v = fn.getGeneValue(name); + String pretty = v == null ? "null" + : (v instanceof Float + ? String.format("%.4f", (Float) v) + : v.toString()); + w.write(String.format(" %-32s = %s%n", name, pretty)); + } catch (Throwable ignored) {} + } + } catch (Throwable t) { + w.write(" (trait enumeration failed: " + t.getMessage() + ")\n"); + } + } + + private static String nullToDash(String s) { return s == null ? "-" : s; } + + private static final class Cluster { + final Cell representative; + final NetworkGenome representativeGenome; + final List members = new ArrayList<>(); + Cluster(Cell rep, NetworkGenome g) { + this.representative = rep; + this.representativeGenome = g; + } + } +} diff --git a/core/src/com/protoevo/core/Simulation.java b/core/src/com/protoevo/core/Simulation.java index 1a5e16c..a45c0cc 100644 --- a/core/src/com/protoevo/core/Simulation.java +++ b/core/src/com/protoevo/core/Simulation.java @@ -232,14 +232,599 @@ public void prepare() Environment.settings.misc.timeBetweenAutoSaves::get, this::createAutoSave ); + timedEventsManager.add( + () -> homeostasisInterval, + this::homeostasisTick + ); + } + + // ===== Homeostatic difficulty controller (PID) ===== + // + // Earlier versions used a static `scale = f(pop/target)` lookup that + // snapped immediately and capped at fixed multipliers. That was too + // rigid — sustained overpopulation didn't cause growing pressure, and + // undercrowding didn't get an extra push to recover. + // + // This is now a proper PID controller on the normalized population error + // e(t) = (pop - target) / target. The control signal u(t) = + // Kp·e + Ki·∫e dt + Kd·de/dt is then mapped through per-parameter + // exponentials onto the four levers (decay, plant ED, meat ED, plant + // contact death). Highlights of the design: + // + // • No hard caps. Sustained over-target population grows the integral + // term over time, so pressure mounts continuously — exactly the + // "actively change grazing over time" behavior wanted. The + // parameters never reach zero/infinity because exp() is bounded by + // `MAX_LOG_DEVIATION`. + // + // • Derivative damping. When pop is rapidly approaching target the + // derivative term softens the response, preventing the classic + // PI-only oscillate-and-overshoot pattern. + // + // • Per-parameter gains. Plant contact-death is a fast lever (high + // gain) — it directly cuts the food supply at its source. Plant + // energy density is a medium lever. Decay rate is slow. Starting + // energy is the gentlest because it only affects future newborns. + // + // • Integral anti-windup. The integral is clamped so a long-term + // extinction or runaway can't lock the controller into max output. + // + // • Active equilibrium. At pop == target the controller does almost + // nothing; tiny errors are nudged away. Above/below, pressure + // gradually builds — this naturally creates the oscillating + // "active competition" the user wanted, with periods of grazing + // stress alternating with growth windows. + private boolean homeostasisEnabled = true; + // 250 → 500. Initial spawn cohort is 500 protozoa from worldgen. With + // target 250 the homeostat saw 100% over-target on the very first tick + // and cranked decay to ~3.7× — draining a typical cell's 250J starting + // energy in ~3 sim-seconds. The diagnostic showed the entire initial + // cohort dying from this artificial throttling, not from real ecology. + // Aligning target with the spawn size lets the new cohort settle + // without immediate hostile pressure; the natural selection-pressure + // ratchet still tightens the world over time. + private int homeostasisTargetPop = 500; + private int homeostasisTargetPlants = 1000; + private float homeostasisInterval = 5f; // sim-seconds between updates + + // PID gains. Levers are all *natural* parameters (food density, decay, + // grazing pressure, chemical drip availability) — never a hard population + // cap, so the population is shaped by selection, not gated. + // + // Tuning notes from extensive sim experience: + // • Earlier the integral was hard-capped at ±30. That worked as crude + // anti-windup but ruined fine tracking: once saturated, the lever + // stayed maxed even as pop crossed target, causing big undershoot + // after a big overshoot. Replaced with a *leaky* integral that + // decays a fixed fraction per tick; this prevents windup naturally + // and lets D actually do the fine-correction work. + // • MAX_LOG_DEVIATION was 7 (≈ 1100× lever swing). That capped + // plantED-style levers at 0.001 — not zero. Bumped to 18 so exp + // can produce effectively-zero multipliers when the controller + // needs to fully shut down a food source. + // • Kd doubled to 3.0 — damps the discrete-PID oscillation that + // comes from a 5-second tick observing a population that can + // change by 10%+ between ticks. + private static final float PID_KP = 1.1f; + private static final float PID_KI = 0.06f; + private static final float PID_KD = 3.0f; + private static final float PID_INTEGRAL_LEAK = 0.08f; // per tick + private static final float MAX_LOG_DEVIATION = 18f; // exp(-18) ≈ 1.5e-8 (effectively zero) + + // Per-parameter gains. Sign = direction (positive control = more pressure): + // over-target → less food, faster decay, gentler newborn energy. + // All levers are *consumption-side* now. Earlier we also drove + // `plant.collisionDestructionRate` (kill plants on grazer contact harder + // when protozoa over target) — but that backfired: dead plants become + // MEAT which then feeds the same overpopulated protozoa we're trying to + // starve. It also caused thousands of plant deaths per second at high u + // since the lever was unbounded. Plant abundance is now solely the plant + // PID's job, leaving this controller to throttle food and accelerate + // starvation. + private static final float GAIN_DECAY = 0.8f; + private static final float GAIN_PLANT_ED = -0.85f; + private static final float GAIN_MEAT_ED = -0.7f; + private static final float GAIN_START_E = -0.5f; + // Reduced from -1.5 to -0.4. A high-magnitude gain combined with the + // leaky integral was producing >1000× chemDrip multipliers at low pop, + // which let cells nibble-feed indefinitely without needing to match + // plant signatures — defeating the whole receptor system. With -0.4 + // the lever's max swing is ~exp(0.4 × ~4) ≈ 5×, so the homeostat + // can still encourage feeding during a crash but can't turn drips + // into a primary food source. Engulf (where receptor match matters) + // is now the dominant feeding path. + private static final float GAIN_CHEM_EXTRACT = -0.4f; + + private float pidIntegral = 0f; + private float pidLastError = Float.NaN; + private long pidLastLogMs = 0; + + // Plant-side PID. Same leaky-integral / high-Kd structure as the + // protozoa controller. Slightly softer Kp (plants react slower than + // protozoa to setting changes, so we want gentler proportional gain + // to avoid chasing noise) and a stronger Kd (plant lifecycle is on + // the same time-scale as the controller tick, so damping matters more). + private static final float PLANT_PID_KP = 0.9f; + private static final float PLANT_PID_KI = 0.05f; + private static final float PLANT_PID_KD = 4.0f; + private static final float PLANT_PID_INTEGRAL_LEAK = 0.10f; + + // Plant levers. Photosynthesis is the heavy lever — directly controls + // energy income, which combined with the universal starvation damage + // gives selection pressure with teeth. Construction rate limits how + // much mass a plant has for growth/repair. Growth rate caps how fast + // they reach split radius. Split-health threshold makes splitting + // impossible when over-target (threshold > 1 → no plant can split). + // The new plantSplitRate lever directly scales the probabilistic + // split rate so even healthy mature plants slow their reproduction + // when over-target, instead of the old "instant burst on reaching + // maxRadius" that caused the divide-then-die churn. + private static final float GAIN_PLANT_PHOTOSYNTHESIS = -1.0f; + private static final float GAIN_PLANT_CONSTRUCTION = -0.8f; + private static final float GAIN_PLANT_GROWTH = -0.7f; + private static final float GAIN_PLANT_SPLIT_HEALTH = 1.2f; + private static final float GAIN_PLANT_SPLIT_RATE = -1.3f; + + private float plantPidIntegral = 0f; + private float plantPidLastError = Float.NaN; + + // Reference values: must match the in-Java defaults set in CellSettings / + // SimulationSettings. Multipliers are computed relative to these. + // Re-interpreted: this is now a FRACTIONAL coefficient applied to + // (radius/minR × energyAvailable) in Cell.decayResources, not a flat + // J/sec rate. Keep in sync with CellSettings.energyDecayRate default. + private static final float HOMEO_BASE_DECAY = 0.005f; + private static final float HOMEO_BASE_PLANT_ED = 2e5f; + private static final float HOMEO_BASE_MEAT_ED = 6e5f; + private static final float HOMEO_BASE_START_E = 50f; + private static final float HOMEO_BASE_CHEM_EXTRACT = 100f; + private static final float HOMEO_BASE_PLANT_PHOTOSYNTHESIS = 300f; + private static final float HOMEO_BASE_PLANT_CONSTRUCTION = 10f; + private static final float HOMEO_BASE_PLANT_MAX_GROWTH = 1.5f; + private static final float HOMEO_BASE_PLANT_MIN_GROWTH = 0f; + private static final float HOMEO_BASE_PLANT_SPLIT_HEALTH = 0.15f; + // Per-second probability a mature plant splits this update. Baseline + // gives ~30 sim-sec adult lifetime before splitting; the PID scales + // this to throttle reproduction without snapping it fully off. + private static final float HOMEO_BASE_PLANT_SPLIT_RATE = 1f / 30f; + + + private void homeostasisTick() { + if (!homeostasisEnabled || environment == null) return; + // Run the plant controller alongside the protozoa one — they target + // different populations with different levers, so they can't interfere. + plantHomeostasisTick(); + // Ratchet the selection-pressure exponent up when conditions allow. + // Independent of the PID — only goes one direction. + maybeRatchetSelection(); + int pop = environment.numberOfProtozoa(); + if (pop <= 0) { + // Extinct: spawn a fresh seeded cohort so the sim keeps running. + // Reset PID integral so we don't yank the freshly-respawned + // world into an over-tight state on the first tick. + pidIntegral = 0f; + pidLastError = Float.NaN; + respawnProtozoaIfExtinct(); + return; + } + + // Normalized error: positive = over target, negative = under. + float error = ((float) pop - homeostasisTargetPop) / (float) homeostasisTargetPop; + + // Leaky integral: each tick decays the accumulated integral by a + // fixed fraction, then adds current error. This bounds the integral + // naturally (steady-state I = error * interval / leak) without the + // hard clamp that caused undershoot after a big overshoot. + pidIntegral = pidIntegral * (1f - PID_INTEGRAL_LEAK) + error * homeostasisInterval; + + // Derivative term — first call has no history, treat as zero. + float derivative = 0f; + if (!Float.isNaN(pidLastError)) + derivative = (error - pidLastError) / homeostasisInterval; + pidLastError = error; + + float control = PID_KP * error + PID_KI * pidIntegral + PID_KD * derivative; + + // Apply per-parameter exponential mapping. This keeps multipliers + // strictly positive and bounded (no zeros, no infinities). + Environment.settings.cell.energyDecayRate.set( + HOMEO_BASE_DECAY * mul(GAIN_DECAY * control)); + Environment.settings.plantEnergyDensity.set( + HOMEO_BASE_PLANT_ED * mul(GAIN_PLANT_ED * control)); + Environment.settings.meatEnergyDensity.set( + HOMEO_BASE_MEAT_ED * mul(GAIN_MEAT_ED * control)); + Environment.settings.cell.startingAvailableCellEnergy.set( + HOMEO_BASE_START_E * mul(GAIN_START_E * control)); + Environment.settings.cell.chemicalExtractionFactor.set( + HOMEO_BASE_CHEM_EXTRACT * mul(GAIN_CHEM_EXTRACT * control)); + + // Log roughly every 30 seconds so an overnight run leaves a record + // of how the controller reacted, without spamming during steady state. + long now = (long)(environment.getElapsedTime() * 1000); + if (pidLastLogMs == 0 || now - pidLastLogMs > 30_000L) { + int plantPop = environment.getCount(com.protoevo.biology.cells.PlantCell.class); + float plantCtrl = PLANT_PID_KP * ((plantPop - homeostasisTargetPlants) / (float) homeostasisTargetPlants) + + PLANT_PID_KI * plantPidIntegral; + System.out.printf( + "[homeostat] proto=%d/%d err=%+.2f u=%+.2f decayx%.2f plantEDx%.3f chemDripx%.3f " + + "plants=%d/%d uP=%+.2f photoSynx%.3f splitRatex%.3f%n", + pop, homeostasisTargetPop, error, control, + mul(GAIN_DECAY * control), mul(GAIN_PLANT_ED * control), + mul(GAIN_CHEM_EXTRACT * control), + plantPop, homeostasisTargetPlants, plantCtrl, + mul(GAIN_PLANT_PHOTOSYNTHESIS * plantCtrl), + mul(GAIN_PLANT_SPLIT_RATE * plantCtrl)); + + // Diagnostic: what's actually inside a typical protozoan? If + // avgFood > 0 but avgConstrMass ≈ 0 the digest path isn't + // converting. If avgFood ≈ 0 too, eat isn't filling food. If + // avgEnergy stays low even after the food-bug fixes, the + // problem is the energy economy, not the food pipeline. + double sumE = 0, sumCM = 0, sumFood = 0, sumR = 0, sumAge = 0; + int n = 0, engulfing = 0, withMass = 0; + for (com.protoevo.biology.cells.Cell c : environment.getCells()) { + if (!(c instanceof com.protoevo.biology.cells.Protozoan)) continue; + com.protoevo.biology.cells.Protozoan p = (com.protoevo.biology.cells.Protozoan) c; + if (p.isDead()) continue; + sumE += p.getEnergyAvailable(); + sumCM += p.getConstructionMassAvailable(); + if (p.getConstructionMassAvailable() > 1e-9f) withMass++; + for (com.protoevo.biology.Food f : p.getFoodToDigest().values()) + sumFood += f.getSimpleMass(); + sumR += p.getRadius(); + sumAge += p.getTimeAlive(); + if (!p.getEngulfedCells().isEmpty()) engulfing++; + n++; + } + if (n > 0) { + System.out.printf("[diag] n=%d avgE=%.1f/%.0f avgCM=%.5g avgFood=%.5g " + + "avgR=%.4f avgAge=%.1fs engulfing=%d withMass=%d%n", + n, sumE / n, + Environment.settings.cell.energyCapFactor.get() * (sumR / n) + / Environment.settings.minParticleRadius.get(), + sumCM / n, sumFood / n, sumR / n, sumAge / n, + engulfing, withMass); + } + + // What's actually killing things this window? Drains the + // per-cause counters from Environment so the next window + // reports a fresh delta. Empty maps print as "[deaths] none" + // so the line is grep-able either way. + logDeathCauses("protozoa", environment.drainProtozoaDeaths()); + logDeathCauses("plants ", environment.drainPlantDeaths()); + + pidLastLogMs = now; + } + } + + private static void logDeathCauses(String label, + java.util.Map counts) { + if (counts == null || counts.isEmpty()) { + System.out.printf("[deaths %s] none%n", label); + return; + } + // Print highest-count first so the dominant killer is immediately visible. + java.util.List> + sorted = new java.util.ArrayList<>(counts.entrySet()); + sorted.sort((a, b) -> Integer.compare(b.getValue(), a.getValue())); + StringBuilder sb = new StringBuilder(); + for (java.util.Map.Entry e : sorted) { + if (sb.length() > 0) sb.append(", "); + sb.append(e.getKey().name()).append("=").append(e.getValue()); + } + System.out.printf("[deaths %s] %s%n", label, sb.toString()); + } + + private void plantHomeostasisTick() { + int plantPop = environment.getCount(com.protoevo.biology.cells.PlantCell.class); + if (plantPop <= 0) { + // All plants gone: clear integral so when seedlings return the + // world isn't already pressed all the way to no-photosynthesis. + plantPidIntegral = 0f; + plantPidLastError = Float.NaN; + return; + } + + float error = ((float) plantPop - homeostasisTargetPlants) / (float) homeostasisTargetPlants; + plantPidIntegral = plantPidIntegral * (1f - PLANT_PID_INTEGRAL_LEAK) + + error * homeostasisInterval; + + float derivative = 0f; + if (!Float.isNaN(plantPidLastError)) + derivative = (error - plantPidLastError) / homeostasisInterval; + plantPidLastError = error; + + float control = PLANT_PID_KP * error + PLANT_PID_KI * plantPidIntegral + PLANT_PID_KD * derivative; + + Environment.settings.plant.photosynthesizeEnergyRate.set( + HOMEO_BASE_PLANT_PHOTOSYNTHESIS * mul(GAIN_PLANT_PHOTOSYNTHESIS * control)); + Environment.settings.plant.constructionRate.set( + HOMEO_BASE_PLANT_CONSTRUCTION * mul(GAIN_PLANT_CONSTRUCTION * control)); + Environment.settings.plant.maxPlantGrowth.set( + HOMEO_BASE_PLANT_MAX_GROWTH * mul(GAIN_PLANT_GROWTH * control)); + Environment.settings.plant.minPlantGrowth.set( + HOMEO_BASE_PLANT_MIN_GROWTH * mul(GAIN_PLANT_GROWTH * control)); + Environment.settings.plant.minHealthToSplit.set( + HOMEO_BASE_PLANT_SPLIT_HEALTH * mul(GAIN_PLANT_SPLIT_HEALTH * control)); + Environment.settings.plant.splitRate.set( + HOMEO_BASE_PLANT_SPLIT_RATE * mul(GAIN_PLANT_SPLIT_RATE * control)); + } + + /** Map a log-deviation into a positive multiplier, clamped so neither + * end can produce 0 or runaway values. */ + private static float mul(float x) { + if (x > MAX_LOG_DEVIATION) x = MAX_LOG_DEVIATION; + if (x < -MAX_LOG_DEVIATION) x = -MAX_LOG_DEVIATION; + return (float) Math.exp(x); + } + + // ===== Selection-pressure ratchet ===== + // + // Monotonic difficulty escalator separate from the PID. The PID keeps the + // population near its target; the ratchet makes the *task* harder over + // time so lineages can never coast. PhagocyticReceptor uses match^(1+p) + // as digestion efficiency, where p is the ratcheted exponent — higher p + // means partial signature matches digest worse and worse. + // + // Ratchet rules: + // * Only fires every RATCHET_INTERVAL sim-seconds (delay so a transient + // stable patch doesn't multiply pressure). + // * Population must be at least RATCHET_POP_FRACTION (80%) of target. + // A world stable at 400/500 still ratchets — only an actively + // crashing or much-under population is filtered out. + // * |derivative| of error must be small (don't tighten while population + // is changing fast either direction). + // * Only goes UP, never down. Cap at MAX_PRESSURE so it can't drive + // evolution to literal impossibility. + // Bumped 60 → 240 sim-seconds. The 60-second cadence was firing 4 + // simultaneous tightening steps (exponent, plant MIN_RUN, protozoa + // MIN_RUN, and base efficiency) every cycle. After 3-4 cycles + // (~3 sim-minutes), engulfBaseEfficiency had dropped from 1.0 to + // ~0.6, which is enough to choke off engulf feeding — cells without + // well-evolved receptors stopped digesting effectively, switched to + // chemical-drip-only survival, and gradually bled out from old age. + // 240-second cadence (4 sim-minutes per step) gives ~30 sim-minutes + // for the receptor system to fully tighten — long enough for several + // reproductive cycles per step, so evolution can actually track + // each new constraint before the next one lands. + private static final float RATCHET_INTERVAL = 240f; + private static final float RATCHET_STEP = 0.05f; // exponent step + private static final float MAX_PRESSURE = 3.0f; // hard cap (match⁴ digest) + private static final float STABLE_DERIV_LIMIT = 0.05f; // |dError/dt| below this counts as stable + // Caps for the MIN_RUN ratchet — sequence lengths are 50 (plant) and 75 + // (protozoa), so caps at 30% and ~27% leave headroom even after + // substantial signature drift. Going higher than this risks the + // signature-drift rate outrunning the receptor-evolution rate and + // crashing the population. + private static final int MAX_PLANT_MIN_RUN = 15; + // Cap raised from 20 → 35 so the ratchet still has climbing room + // above the new PhagocyticReceptor PROTOZOA_MIN_RUN_FLOOR (24). + // 35 of 75 = 47% of sequence — borderline impossible even with + // strong evolutionary pressure; effectively a hard ceiling on the + // cannibalism arms race. + private static final int MAX_PROTOZOA_MIN_RUN = 35; + private float lastRatchetSimTime = -RATCHET_INTERVAL; // -interval so first eligible tick fires + + // What fraction of the target population is "close enough to stable" to + // count as ratchet-eligible? 0.8 = if the world settles at 80% of + // target and stays there, that's a valid (if slightly undersized) + // equilibrium — the ratchet still fires so the difficulty curve doesn't + // stall just because the world isn't QUITE at the nominal target. + // Previously this was 1.0 (strict >= target), which meant a world that + // settled at 480/500 would never tighten. + private static final float RATCHET_POP_FRACTION = 0.8f; + + private void maybeRatchetSelection() { + float now = environment.getElapsedTime(); + if (now - lastRatchetSimTime < RATCHET_INTERVAL) return; + + int pop = environment.numberOfProtozoa(); + // Ratchet-eligibility threshold lowered from `pop >= target` to + // `pop >= target × 0.8`. Combined with the stability check below, + // this means "the world is stable at or close-to target", not the + // strict "at-or-over target" that left a slightly-undersized + // equilibrium permanently un-ratcheted. The pressure still scales + // only with stability — a crashing population (pop falling fast) + // is filtered out by the derivative check, regardless of absolute + // pop level. + if (pop < homeostasisTargetPop * RATCHET_POP_FRACTION) return; + + // Use the most recent derivative the PID just computed. NaN guard + // because the very first homeostat tick has no history. + float deriv = Float.isNaN(pidLastError) ? 0f + : (((float) pop - homeostasisTargetPop) / homeostasisTargetPop - pidLastError) + / homeostasisInterval; + if (Math.abs(deriv) > STABLE_DERIV_LIMIT) return; // changing fast — wait + + boolean stepped = false; + + // 1. Exponent ratchet (digestion-efficiency curve gets steeper). + float current = Environment.settings.cell.selectionPressureExponent.get(); + if (current < MAX_PRESSURE) { + float next = Math.min(MAX_PRESSURE, current + RATCHET_STEP); + Environment.settings.cell.selectionPressureExponent.set(next); + System.out.printf( + "[ratchet] selectionPressureExponent %.3f -> %.3f (digest = match^%.2f)%n", + current, next, 1f + next); + stepped = true; + } + + // 2. MIN_RUN ratchet (engulf gate gets stricter). Independent of the + // exponent ratchet — both can fire on the same tick. Without this + // second axis, a stable population at high exponent + low MIN_RUN + // would coast on weak receptors that scrape past a tiny gate; + // raising the gate forces co-evolution of longer binding regions. + int curPlantRun = Environment.settings.cell.plantEngulfMinRun.get(); + if (curPlantRun < MAX_PLANT_MIN_RUN) { + int next = curPlantRun + 1; + Environment.settings.cell.plantEngulfMinRun.set(next); + System.out.printf( + "[ratchet] plantEngulfMinRun %d -> %d (of 50)%n", + curPlantRun, next); + stepped = true; + } + int curProtoRun = Environment.settings.cell.protozoaEngulfMinRun.get(); + if (curProtoRun < MAX_PROTOZOA_MIN_RUN) { + int next = curProtoRun + 1; + Environment.settings.cell.protozoaEngulfMinRun.set(next); + System.out.printf( + "[ratchet] protozoaEngulfMinRun %d -> %d (of 75)%n", + curProtoRun, next); + stepped = true; + } + + // 3. Engulf base efficiency ratchet (digestion floor gets lower). + // Drops from 1.0 (receptor system off — every engulf is full + // efficiency) toward 0.02 (the original hard floor — only good + // matches digest well). Geometric decay: each step multiplies + // by 0.85 so the floor halves every ~4 ratchet ticks (~4 min + // sim time at the default RATCHET_INTERVAL=60). Lineages have + // plenty of generations to evolve specific receptors before + // the floor approaches the hard minimum. + float curBase = Environment.settings.cell.engulfBaseEfficiency.get(); + if (curBase > 0.02f) { + float next = Math.max(0.02f, curBase * 0.85f); + Environment.settings.cell.engulfBaseEfficiency.set(next); + System.out.printf( + "[ratchet] engulfBaseEfficiency %.3f -> %.3f%n", + curBase, next); + stepped = true; + } + + if (stepped) + lastRatchetSimTime = now; } + // ===== Auto-respawn on extinction ===== + // + // When all protozoa die, spawn a fresh seeded cohort so the sim keeps + // running unattended. New cells get a plantReceptorKey copied from + // some live plant's surface signature → they can immediately eat that + // plant lineage at full rate. Their protozoa receiving / phagocytic + // receptors are fresh independent random sequences (so they can't + // kin-cannibalize, same logic as initialisePopulation). + // + // If there are no live plants either, we don't respawn — that would + // just spawn cells with no food. The sim continues running empty + // and the user can fix it manually. + private static final int RESPAWN_PROTOZOA_COUNT = 250; + + private void respawnProtozoaIfExtinct() { + if (environment == null) return; + if (environment.numberOfProtozoa() > 0) return; + + // Find a representative live plant to align the new protozoa keys to. + com.protoevo.biology.cells.PlantCell anchor = null; + for (com.protoevo.biology.cells.Cell c : new java.util.ArrayList<>(environment.getCells())) { + if (c instanceof com.protoevo.biology.cells.PlantCell && !c.isDead()) { + anchor = (com.protoevo.biology.cells.PlantCell) c; + break; + } + } + com.protoevo.biology.evolution.AminoAcidSequence anchorSig = + anchor == null ? null : anchor.getSurfaceSignature(); + if (anchorSig == null) { + System.out.println("[respawn] no live plants found; skipping respawn so we don't spawn starvers."); + return; + } + + // Fresh independent keys for the protozoa-on-protozoa side so the + // new cohort can't immediately kin-cannibalize. + com.protoevo.biology.evolution.AminoAcidSequence freshReceiving = + new com.protoevo.biology.evolution.AminoAcidSequence( + com.protoevo.biology.evolution.ProtozoaSignatureTrait.LENGTH); + com.protoevo.biology.evolution.AminoAcidSequence freshPhag = + new com.protoevo.biology.evolution.AminoAcidSequence( + com.protoevo.biology.evolution.ProtozoaSignatureTrait.LENGTH); + + int target = Math.min(RESPAWN_PROTOZOA_COUNT, + environment.getGlobalCapacity(com.protoevo.biology.cells.Protozoan.class)); + int spawned = 0; + for (int i = 0; i < target; i++) { + try { + com.protoevo.biology.cells.Protozoan p = + com.protoevo.biology.evolution.Evolvable.createNew( + com.protoevo.biology.cells.Protozoan.class); + p.setPlantReceptorKey( + new com.protoevo.biology.evolution.AminoAcidSequence(anchorSig)); + p.setProtozoaReceivingReceptor( + new com.protoevo.biology.evolution.AminoAcidSequence(freshReceiving)); + p.setProtozoaPhagocyticReceptor( + new com.protoevo.biology.evolution.AminoAcidSequence(freshPhag)); + p.setEnvironmentAndBuildPhysics(environment); + environment.findRandomPositionOrKillCell(p); + spawned++; + } catch (Throwable t) { + // If anything goes wrong creating a cell, skip it; we still + // want to spawn as many as possible. + } + } + System.out.printf("[respawn] extinction detected — spawned %d new protozoa, keys seeded against plant lineage with sig %s%n", + spawned, anchorSig.toString()); + } + + public boolean isHomeostasisEnabled() { return homeostasisEnabled; } + public void setHomeostasisEnabled(boolean enabled) { + homeostasisEnabled = enabled; + if (!enabled) { + // Restore baselines AND reset PID state so re-enabling later + // starts from a clean slate, not the integral we'd built up. + Environment.settings.cell.energyDecayRate.set(HOMEO_BASE_DECAY); + Environment.settings.plantEnergyDensity.set(HOMEO_BASE_PLANT_ED); + Environment.settings.meatEnergyDensity.set(HOMEO_BASE_MEAT_ED); + Environment.settings.cell.startingAvailableCellEnergy.set(HOMEO_BASE_START_E); + Environment.settings.cell.chemicalExtractionFactor.set(HOMEO_BASE_CHEM_EXTRACT); + Environment.settings.plant.photosynthesizeEnergyRate.set(HOMEO_BASE_PLANT_PHOTOSYNTHESIS); + Environment.settings.plant.constructionRate.set(HOMEO_BASE_PLANT_CONSTRUCTION); + Environment.settings.plant.maxPlantGrowth.set(HOMEO_BASE_PLANT_MAX_GROWTH); + Environment.settings.plant.minPlantGrowth.set(HOMEO_BASE_PLANT_MIN_GROWTH); + Environment.settings.plant.minHealthToSplit.set(HOMEO_BASE_PLANT_SPLIT_HEALTH); + Environment.settings.plant.splitRate.set(HOMEO_BASE_PLANT_SPLIT_RATE); + pidIntegral = 0f; + pidLastError = Float.NaN; + plantPidIntegral = 0f; + plantPidLastError = Float.NaN; + pidLastLogMs = 0; + } + System.out.println("Homeostat: " + (enabled ? "ON" : "OFF (defaults restored)")); + } + public int getHomeostasisTargetPop() { return homeostasisTargetPop; } + public void setHomeostasisTargetPop(int target) { + homeostasisTargetPop = Math.max(10, target); + // Reset integral so the controller doesn't carry over windup from the + // old setpoint into the new one — common cause of big initial swings + // after a target change. + pidIntegral = 0f; + pidLastError = Float.NaN; + System.out.println("Homeostat target population: " + homeostasisTargetPop); + } + public int getHomeostasisTargetPlants() { return homeostasisTargetPlants; } + public void setHomeostasisTargetPlants(int target) { + homeostasisTargetPlants = Math.max(10, target); + plantPidIntegral = 0f; + plantPidLastError = Float.NaN; + System.out.println("Homeostat target plants: " + homeostasisTargetPlants); + } + // ===== end Homeostatic controller ===== + public void cancelPreparation() {} public void run() { while (simulate) { - if (paused) + if (paused) { + // Sleep briefly instead of busy-waiting. The original tight + // `if (paused) continue;` pinned a full CPU core whenever the + // sim was paused, which starved the render thread enough that + // opening a heavy modal screen could make Windows mark the + // LWJGL window "not responding". + try { + Thread.sleep(20); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } continue; + } update(); @@ -273,10 +858,63 @@ public void update() return; try { - float delta = timeDilation * Environment.settings.simulationUpdateDelta.get(); + // Sim-time advancement model: + // one render call advances sim time by `timeDilation` × baseDt. + // At low td we run the inner loop with dt=baseDt (small, stable). + // At high td we'd otherwise call physics+cell update hundreds of + // times per frame; instead we use BIGGER physics steps capped at a + // stability ceiling that grows with td, so 128× ends up doing + // ~8 medium steps per render frame instead of 128 tiny ones. Cell + // logic is linear in dt so batching is safe; physics has loads of + // headroom because max cell speed is ~0.05 units/s (terminal + // velocity under fluid damping 10 with cilia thrust 0.0005); even + // dt=0.05 won't tunnel a 0.02-radius cell. Chemicals need the + // per-pixel cap in protozoanIO (already in place) to be safe. + float baseDt = Environment.settings.simulationUpdateDelta.get(); + float td = Math.max(0f, timeDilation); + float totalDt = td * baseDt; + + // Stability ceiling scales with time dilation so the per-render + // step count stays small even at very high td. Max cell speed is + // ~0.05 units/s, min radius ~0.02, so even dt=0.1s only moves a + // cell 1/4 of its diameter per step — Box2D handles this fine. + // At td=1×: stepDt = 8×baseDt = 0.008 (1 step per render) + // At td=32: stepDt = 8×baseDt → 4 steps per render + // At td=128: stepDt = 32×baseDt = 0.032 → 4 steps per render + // At td=256: stepDt = 64×baseDt = 0.064 → 4 steps per render + float maxStableStep = baseDt * Math.min(64f, Math.max(8f, td / 4f)); + // Per-render-frame cap on the number of big steps. Above this the + // sim just won't keep up — better to run slow than hang. + int maxStepsPerFrame = 128; try { - environment.update(delta); + // Chemicals AND plant/meat updates are batched once per render + // frame. Both are expensive (chem deposit is a parallel stream + // over all cells painting their footprint; plant cell updates + // are 1500× per pass and mostly do linear-in-delta work) and + // neither benefits from sub-frame granularity. Per-pixel + // extraction cap in protozoanIO makes the chem batching safe; + // plant/meat batching is safe because their update is linear + // in delta and Environment accumulates skipped dt internally. + // Protozoa still update every step — they're the active + // agents and need sub-frame collision/eating fidelity. + float remaining = totalDt; + float pendingChem = 0f; + int stepsThisFrame = 0; + while (remaining > 1e-9f && stepsThisFrame < maxStepsPerFrame) { + float stepDt = Math.min(remaining, maxStableStep); + pendingChem += stepDt; + boolean isLastStep = + (remaining - stepDt) <= 1e-9f + || (stepsThisFrame + 1) >= maxStepsPerFrame; + float chemDt = isLastStep ? pendingChem : 0f; + environment.update(stepDt, chemDt, isLastStep); + timedEventsManager.update(stepDt); + if (isLastStep) + pendingChem = 0f; + remaining -= stepDt; + stepsThisFrame++; + } } catch (Exception e) { writeCrashReport(e); e.printStackTrace(); @@ -286,8 +924,6 @@ public void update() throw e; } - timedEventsManager.update(delta); - } catch (Exception e) { writeCrashReport(e); System.exit(0); @@ -368,6 +1004,17 @@ public String save() { String timeStamp = Utils.getTimeStampString(); String fileName = getSaveFolder() + "/env/" + timeStamp; Serialization.saveEnvironment(environment, fileName); + + // Dump a human/AI-readable report on whatever lineage is currently + // dominant. Lets us inspect what evolution actually built without + // having to deserialize the binary env file. + try { + com.protoevo.core.DominantLineageReport.write( + environment, getSaveFolder() + "/dominant_lineage.txt"); + } catch (Throwable t) { + System.out.println("[dominant-lineage] failed: " + t.getMessage()); + } + return fileName; } diff --git a/core/src/com/protoevo/core/SimulationHistory.java b/core/src/com/protoevo/core/SimulationHistory.java index cdc84ac..bbea1cf 100644 --- a/core/src/com/protoevo/core/SimulationHistory.java +++ b/core/src/com/protoevo/core/SimulationHistory.java @@ -12,7 +12,26 @@ public class SimulationHistory { - private final Map statistics = new HashMap<>(); + // In-memory snapshot retention. Snapshots fire every + // `misc.statisticsSnapshotTime` sim-seconds (default 20s), and the previous + // implementation kept *every* snapshot for the whole run — at default + // settings that's 180/hour, ~4300/day, plus the protozoa summary stats + // stored per snapshot (which include log-distributions per trait). After + // an overnight session this map alone would balloon to hundreds of MB and + // dominate save time, GC pauses, and plot extraction. + // + // Disk writes are unaffected — every snapshot still goes to + // stats/summaries/.json. This cap only governs what stays in + // RAM for live plotting / extractData() calls. + private static final int MAX_IN_MEMORY_SNAPSHOTS = 500; + + private final LinkedHashMap statistics = + new LinkedHashMap(MAX_IN_MEMORY_SNAPSHOTS * 2, 0.75f, false) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > MAX_IN_MEMORY_SNAPSHOTS; + } + }; private final String statsFolder; private final Set commonStatsKeys; diff --git a/core/src/com/protoevo/core/repl/DumpGenome.java b/core/src/com/protoevo/core/repl/DumpGenome.java new file mode 100644 index 0000000..79fb406 --- /dev/null +++ b/core/src/com/protoevo/core/repl/DumpGenome.java @@ -0,0 +1,175 @@ +package com.protoevo.core.repl; + +import com.protoevo.biology.cells.Cell; +import com.protoevo.biology.cells.Protozoan; +import com.protoevo.biology.nn.NetworkGenome; +import com.protoevo.biology.nn.NeuralNetwork; +import com.protoevo.biology.nn.Neuron; +import com.protoevo.biology.nn.SynapseGene; +import com.protoevo.core.Simulation; +import com.protoevo.utils.FileIO; +import com.protoevo.utils.Utils; + +import java.util.ArrayList; +import java.util.List; + +/** + * Dumps a single protozoa's neural network state to JSON so it can be + * inspected outside the running sim. The format is intentionally minimal: + * just neurons (id, label, type) and synapses (in, out, weight, disabled). + * Enough to reconstruct the matrix and reason about which sensors drive + * which outputs. + */ +public class DumpGenome extends Command { + public DumpGenome(REPL repl) { + super(repl); + } + + @Override + public boolean run(String[] args) { + Simulation sim = repl.getSimulation(); + Protozoan target = null; + + if (args.length >= 2) { + // Argument can be a cell id or "biggest" for the protozoa + // with the most living descendants. + String arg = args[1]; + if (arg.equals("biggest")) { + target = findBiggestLineageProtozoan(sim); + } else { + try { + long id = Long.parseLong(arg); + for (Cell c : sim.getEnv().getCells()) { + if (c.getId() == id && c instanceof Protozoan) { + target = (Protozoan) c; + break; + } + } + } catch (NumberFormatException e) { + System.out.println("Invalid id: " + arg); + return false; + } + } + } + + // Fallback: just pick any protozoa. + if (target == null) { + for (Cell c : sim.getEnv().getCells()) { + if (c instanceof Protozoan) { + target = (Protozoan) c; + break; + } + } + } + + if (target == null) { + System.out.println("No protozoa to dump."); + return false; + } + + Dump dump = buildDump(target); + String fileName = sim.getSaveFolder() + "/genome-" + + target.getId() + "-" + Utils.getTimeStampString(); + FileIO.writeJson(dump, fileName); + System.out.println("Wrote: " + fileName + ".json"); + System.out.printf(" protozoan id=%d lineage=%d gen=%d neurons=%d synapses=%d%n", + target.getId(), target.getLineageId(), target.getGeneration(), + dump.neurons.size(), dump.synapses.size()); + return true; + } + + private Protozoan findBiggestLineageProtozoan(Simulation sim) { + Protozoan best = null; + int bestSize = -1; + for (Cell c : sim.getEnv().getCells()) { + if (!(c instanceof Protozoan)) continue; + var rec = sim.getEnv().getLineageRecords().get(c.getLineageId()); + int size = rec != null ? rec.aliveDescendants : 0; + if (size > bestSize) { + bestSize = size; + best = (Protozoan) c; + } + } + return best; + } + + private Dump buildDump(Protozoan p) { + NetworkGenome genome = p.getGeneExpressionFunction().getGRNGenome(); + NeuralNetwork phenotype = p.getGeneExpressionFunction().getRegulatoryNetwork(); + + Dump d = new Dump(); + d.protozoanId = p.getId(); + d.lineageId = p.getLineageId(); + d.generation = p.getGeneration(); + d.mutationRateMultiplier = genome.getMutationRateMultiplier(); + + // Walk genome neurons (genes, not phenotype) for stable labels. + var it = genome.iterateNeuronGenes(); + while (it.hasNext()) { + var gene = it.next(); + NeuronDump n = new NeuronDump(); + n.id = gene.getId(); + n.label = gene.getLabel(); + n.type = gene.getType().toString(); + // Pull live activation from the phenotype if available. + if (phenotype != null) { + for (Neuron neuron : phenotype.getNeurons()) { + if (neuron.getId() == n.id) { + n.lastState = neuron.getLastState(); + break; + } + } + } + d.neurons.add(n); + } + for (SynapseGene s : genome.getSynapseGenes()) { + if (s.isDisabled()) continue; // skip pruned connections + SynapseDump sd = new SynapseDump(); + sd.from = s.getIn().getId(); + sd.to = s.getOut().getId(); + sd.weight = s.getWeight(); + d.synapses.add(sd); + } + return d; + } + + @Override + public String[] getAliases() { + return new String[]{"dumpgenome", "dumpnn"}; + } + + @Override + public String getDescription() { + return "Dump a protozoa's NN to JSON."; + } + + @Override + public void printUsage() { + System.out.println("Usage:"); + System.out.println(" dumpgenome — pick any protozoa"); + System.out.println(" dumpgenome biggest — protozoa from the largest lineage"); + System.out.println(" dumpgenome — specific cell by id"); + System.out.println("Output JSON is written to the simulation's save folder."); + } + + // ===== JSON shape ===== + public static class Dump { + public long protozoanId; + public long lineageId; + public int generation; + public float mutationRateMultiplier; + public List neurons = new ArrayList<>(); + public List synapses = new ArrayList<>(); + } + public static class NeuronDump { + public int id; + public String label; + public String type; + public float lastState; + } + public static class SynapseDump { + public int from; + public int to; + public float weight; + } +} diff --git a/core/src/com/protoevo/core/repl/Homeostat.java b/core/src/com/protoevo/core/repl/Homeostat.java new file mode 100644 index 0000000..d023b21 --- /dev/null +++ b/core/src/com/protoevo/core/repl/Homeostat.java @@ -0,0 +1,77 @@ +package com.protoevo.core.repl; + +import com.protoevo.core.Simulation; + +public class Homeostat extends Command { + + public Homeostat(REPL repl) { + super(repl); + } + + @Override + public boolean run(String[] args) { + Simulation sim = repl.getSimulation(); + if (args.length == 1) { + System.out.printf("Homeostat: %s, protozoa target=%d, plant target=%d%n", + sim.isHomeostasisEnabled() ? "ON" : "OFF", + sim.getHomeostasisTargetPop(), + sim.getHomeostasisTargetPlants()); + return true; + } + String sub = args[1].toLowerCase(); + switch (sub) { + case "on": + sim.setHomeostasisEnabled(true); + return true; + case "off": + sim.setHomeostasisEnabled(false); + return true; + case "target": + if (args.length < 3) { + System.out.println("Usage: homeostat target "); + return false; + } + try { + sim.setHomeostasisTargetPop(Integer.parseInt(args[2])); + return true; + } catch (NumberFormatException e) { + System.out.println("Invalid integer: " + args[2]); + return false; + } + case "plants": + if (args.length < 3) { + System.out.println("Usage: homeostat plants "); + return false; + } + try { + sim.setHomeostasisTargetPlants(Integer.parseInt(args[2])); + return true; + } catch (NumberFormatException e) { + System.out.println("Invalid integer: " + args[2]); + return false; + } + default: + printUsage(); + return false; + } + } + + @Override + public String[] getAliases() { + return new String[]{"homeostat", "homeo"}; + } + + @Override + public String getDescription() { + return "Inspect or control the population homeostat (PID)."; + } + + @Override + public void printUsage() { + System.out.println("Usage:"); + System.out.println(" homeostat — show status"); + System.out.println(" homeostat on|off — toggle controller"); + System.out.println(" homeostat target — set protozoa target population"); + System.out.println(" homeostat plants — set plant target population"); + } +} diff --git a/core/src/com/protoevo/core/repl/REPL.java b/core/src/com/protoevo/core/repl/REPL.java index a958a72..a55f5c3 100644 --- a/core/src/com/protoevo/core/repl/REPL.java +++ b/core/src/com/protoevo/core/repl/REPL.java @@ -29,6 +29,8 @@ public REPL(Simulation simulation) { new SimParams(this), new ManageRemoteGraphics(this), new Screenshot(this), + new Homeostat(this), + new DumpGenome(this), }; for (Command command : commandsList) { @@ -56,6 +58,16 @@ public void run() { System.out.print("> "); line = bufferRead.readLine(); + // When stdin is closed (headless launcher, piped command that + // finished, etc.), readLine() returns null. The old code + // immediately NPE'd on line.equals(...), caught the exception, + // printed "Failed with message: null", and looped — a hot + // CPU-burning loop that pinned one core forever and could + // starve other threads. Detect null and shut the REPL cleanly. + if (line == null) { + running = false; + break; + } if (line.equals("\n") || stripWhitespace(line).equals("")) { System.out.println(); continue; diff --git a/core/src/com/protoevo/env/ChemicalSolution.java b/core/src/com/protoevo/env/ChemicalSolution.java index 39e1f6d..28d2536 100644 --- a/core/src/com/protoevo/env/ChemicalSolution.java +++ b/core/src/com/protoevo/env/ChemicalSolution.java @@ -4,6 +4,7 @@ import com.badlogic.gdx.math.Vector2; import com.protoevo.biology.Food; import com.protoevo.biology.cells.Cell; +import com.protoevo.biology.cells.PlantCell; import com.protoevo.biology.cells.Protozoan; import com.protoevo.maths.Functions; import com.protoevo.maths.Geometry; @@ -76,15 +77,37 @@ public void initialise() { initialised = true; } + // Try CUDA first if requested AND available. If the user toggled + // useCUDA but JCuda's runtime/native libs aren't actually loadable, + // cudaAvailable() returns false; we still want a working diffusion + // path, so fall through to OpenGL (and finally CPU). + boolean cudaTriedAndAvailable = false; if (Environment.settings.misc.useCUDA.get() && JCudaKernelRunner.cudaAvailable()) { - // has to be called on the same thread running the simulation - if (DebugMode.isDebugMode()) - System.out.println("Initialising chemical diffusion CUDA kernel..."); - cudaDiffusionKernel = new JCudaKernelRunner("diffusion"); + try { + if (DebugMode.isDebugMode()) + System.out.println("Initialising chemical diffusion CUDA kernel..."); + cudaDiffusionKernel = new JCudaKernelRunner("diffusion"); + cudaTriedAndAvailable = true; + } catch (Throwable t) { + System.out.println("CUDA kernel init failed (" + t.getMessage() + + ") — falling back to OpenGL/CPU diffusion."); + cudaDiffusionKernel = null; + } } - else if (Environment.settings.misc.useOpenGLComputeShader.get()){ - openGLDiffusionShader = new GLComputeShaderRunner("diffusion"); + // OpenGL is the fallback whenever CUDA didn't materialize, OR + // when the user explicitly requested OpenGL. CPU is the final + // fallback (no init needed for cpuDiffuse). + if (!cudaTriedAndAvailable + && (Environment.settings.misc.useOpenGLComputeShader.get() + || Environment.settings.misc.useCUDA.get())) { + try { + openGLDiffusionShader = new GLComputeShaderRunner("diffusion"); + } catch (Throwable t) { + System.out.println("OpenGL compute shader init failed (" + + t.getMessage() + ") — falling back to CPU diffusion."); + openGLDiffusionShader = null; + } } } @@ -126,10 +149,22 @@ public int toChemicalGridY(float y) { return (int) Functions.clampedLinearRemap(y, yMin, yMax, 0, chemicalTextureHeight); } + // Dirty flag so the renderer knows when to refresh its pixmap. Volatile + // because sim thread writes, render thread reads. We DON'T fire a + // per-pixel callback on every set() — at 1024×1024 with a diffuse step + // touching every pixel, that was ~1M JNI calls into Pixmap.drawPixel + // per chemical-update, ~30ms of pure render-thread tax. Renderer now + // batches the whole array into the pixmap once per render frame. + private volatile transient boolean chemicalsDirty = false; + + public boolean isDirty() { return chemicalsDirty; } + public void clearDirty() { chemicalsDirty = false; } + public void set(int x, int y, Colour colour) { if (outOfTextureBounds(x, y)) return; colours[x][y].set(colour); + chemicalsDirty = true; if (updateChemicalCallback != null) updateChemicalCallback.onChemicalUpdated(x, y, colour); } @@ -138,6 +173,7 @@ public void set(int x, int y, float r, float g, float b, float a) { if (outOfTextureBounds(x, y)) return; colours[x][y].set(r, g, b, a); + chemicalsDirty = true; if (updateChemicalCallback != null) updateChemicalCallback.onChemicalUpdated(x, y, colours[x][y]); } @@ -146,6 +182,7 @@ public void set(int x, int y, int rgba8888) { if (outOfTextureBounds(x, y)) return; colours[x][y].set(rgba8888); + chemicalsDirty = true; if (updateChemicalCallback != null) updateChemicalCallback.onChemicalUpdated(x, y, colours[x][y]); } @@ -159,8 +196,19 @@ public void cellChemicalIO(float delta, Cell e) { if (e.isEdible() && !e.isDead()) { Colour cellColour = e.getColour(); + // Plant cells radiate a CHEMICAL HALO at ~1.8× their physical + // radius — bigger than physical footprint to feed cells in + // gaps between plants, but small enough that we don't paint + // 9× more pixels per plant per tick (the 3× version was + // ~9× the depositCircle work and made the sim feel sluggish + // at high plant counts). 1.8× covers ~3.2× the area, enough + // for diffusion to bridge gaps without crushing per-frame + // throughput. + float depositR = (e instanceof PlantCell) + ? e.getRadius() * 1.8f + : e.getRadius(); depositCircle( - e.getPos(), e.getRadius(), + e.getPos(), depositR, cellColour.r, cellColour.g, cellColour.b, 1f); } else if (e instanceof Protozoan) { @@ -178,6 +226,9 @@ private void protozoanIO(float delta, Protozoan protozoan) { float cellWorldWidth = getFieldWidth() / chemicalTextureWidth; float cellWorldHeight = getFieldHeight() / chemicalTextureHeight; + float plantConv = Environment.settings.cell.chemicalExtractionPlantConversion.get(); + float meatConv = Environment.settings.cell.chemicalExtractionMeatConversion.get(); + float extractionFactor = Environment.settings.cell.chemicalExtractionFactor.get(); for (int i = -size; i <= size; i++) { for (int j = -size; j <= size; j++) { @@ -197,21 +248,28 @@ private void protozoanIO(float delta, Protozoan protozoan) { worldX, worldY, protozoan.getRadius() ); float overlapP = overlapArea / (cellWorldWidth * cellWorldHeight); - float extraction = - Environment.settings.cell.chemicalExtractionFactor.get() * delta * overlapP; - if (extraction > 0) { - - if (colour.g > 0.5f && colour.g > 1.5f * colour.r && colour.g > 1.5f * colour.b) + float requested = extractionFactor * delta * overlapP; + if (requested > 0) { + // Cap extraction by what's actually present per + // channel. Without this, batching N substeps' worth + // into a single call (fast-forward) silently + // over-credited food: the OLD colour values were + // multiplied into the food yield even after the + // pixel was about to deplete to zero. With the cap, + // the same batched call extracts exactly what's + // there — correct and delta-safe. + if (colour.g > 0.5f && colour.g > 1.5f * colour.r && colour.g > 1.5f * colour.b) { + float effG = Math.min(requested, colour.g); protozoan.addFood(Food.Type.Plant, - extraction * colour.g * colour.g - * Environment.settings.cell.chemicalExtractionPlantConversion.get()); - - if (colour.r > 0.5f && colour.r > 1.5f * colour.g && colour.r > 1.5f * colour.b) + effG * colour.g * plantConv); + } + if (colour.r > 0.5f && colour.r > 1.5f * colour.g && colour.r > 1.5f * colour.b) { + float effR = Math.min(requested, colour.r); protozoan.addFood(Food.Type.Meat, - extraction * colour.r * colour.r - * Environment.settings.cell.chemicalExtractionMeatConversion.get()); + effR * colour.r * meatConv); + } - colour.sub(extraction); + colour.sub(requested); set(fieldX, fieldY, colour); } } @@ -255,24 +313,43 @@ private void unloadFromByteBuffer() { } private void cudaDiffuse() { - loadIntoByteBuffer(); try { if (cudaDiffusionKernel == null) initialise(); + if (cudaDiffusionKernel == null) { + // initialise() couldn't bring CUDA back up — fall through + // to a non-CUDA path on this tick rather than NPE. + if (openGLDiffusionShader != null) { + openGLDiffuse(); + } else { + cpuDiffuse(); + } + return; + } + cudaDiffusionKernel.processImage( byteBuffer, chemicalTextureWidth, chemicalTextureHeight); } catch (Exception e) { - if (e.getMessage().contains("CUDA_ERROR_INVALID_CONTEXT") || - e.getMessage().contains("CUDA_ERROR_INVALID_HANDLE")) { + String msg = e.getMessage() == null ? "" : e.getMessage(); + if (msg.contains("CUDA_ERROR_INVALID_CONTEXT") || + msg.contains("CUDA_ERROR_INVALID_HANDLE")) { if (DebugMode.isDebugMode()) System.out.println("CUDA context lost, reinitialising..."); initialise(); } else { - throw e; + System.out.println("CUDA diffuse failed: " + msg + + " — falling back to OpenGL/CPU diffusion permanently."); + cudaDiffusionKernel = null; + // Initialize OpenGL on first failure so the next call can use it. + if (openGLDiffusionShader == null) { + try { + openGLDiffusionShader = new GLComputeShaderRunner("diffusion"); + } catch (Throwable t) { /* will fall to CPU */ } + } } } @@ -350,10 +427,17 @@ private void diffuseAt(int x, int y) { tmp[1] += decay * pixel.g * pixel.a; tmp[2] += decay * pixel.b * pixel.a; } - tmp[0] = tmp[0] / ((float) (FILTER_SIZE*FILTER_SIZE)) * decay / final_alpha; - tmp[1] = tmp[1] / ((float) (FILTER_SIZE*FILTER_SIZE)) * decay / final_alpha; - tmp[2] = tmp[2] / ((float) (FILTER_SIZE*FILTER_SIZE)) * decay / final_alpha; } + // Normalize ONCE after both loops finish. Previous code did this + // INSIDE the outer i-loop, so it fired FILTER_SIZE times (=3), + // dividing the running accumulator by 9 and by final_alpha on each + // pass while the inner loop kept adding new contributions. Net + // effect: CPU-fallback diffusion output was ~1000× too small and + // non-uniformly wrong. Only matters when CUDA + OpenGL both fail. + float norm = (decay / final_alpha) / ((float) (FILTER_SIZE * FILTER_SIZE)); + tmp[0] *= norm; + tmp[1] *= norm; + tmp[2] *= norm; newColour.r = tmp[0]; newColour.g = tmp[1]; newColour.b = tmp[2]; @@ -371,9 +455,18 @@ public void cpuDiffuse() { } public void diffuse() { - if (Environment.settings.misc.useCUDA.get()) + // Dispatch on what's actually INITIALIZED, not just on what's + // requested in settings. If you toggle useCUDA but the kernel + // failed to construct (drivers, missing native libs, etc.), the + // kernel is null and we must fall back rather than NPE. + if (Environment.settings.misc.useCUDA.get() && cudaDiffusionKernel != null) cudaDiffuse(); - else if (Environment.settings.misc.useOpenGLComputeShader.get()) + else if (Environment.settings.misc.useOpenGLComputeShader.get() + && openGLDiffusionShader != null) + openGLDiffuse(); + else if (cudaDiffusionKernel != null) + cudaDiffuse(); // implicit fallback if user setting changed mid-run + else if (openGLDiffusionShader != null) openGLDiffuse(); else cpuDiffuse(); diff --git a/core/src/com/protoevo/env/Chunks.java b/core/src/com/protoevo/env/Chunks.java index 08e00a5..a054b13 100644 --- a/core/src/com/protoevo/env/Chunks.java +++ b/core/src/com/protoevo/env/Chunks.java @@ -39,6 +39,12 @@ public void add(Cell cell) { cellHashes.get(cell.getClass()).add(cell, cell.getPos()); } + public void remove(Cell cell) { + SpatialHash hash = cellHashes.get(cell.getClass()); + if (hash != null) + hash.remove(cell, cell.getPos()); + } + public int getLocalCount(Class cellClass) { return cellHashes.get(cellClass).size(); } @@ -48,13 +54,13 @@ public int getGlobalCount(Cell cell) { } public int getGlobalCount(Class cellClass) { - if (!globalCellCounts.containsKey(cellClass)) { - int count = getSpatialHash(cellClass).getChunkIndices().stream() - .mapToInt(i -> getSpatialHash(cellClass).getCount(i)) - .sum(); - globalCellCounts.put(cellClass, count); - } - return globalCellCounts.get(cellClass); + // SpatialHash maintains its own running size on add/remove/clear, so + // we don't need to iterate all chunks summing per-chunk counts. The + // old version was also stale until the next chunks.clear() — it + // cached the sum and only invalidated on full rebuild, which lied + // through births/deaths. + SpatialHash hash = cellHashes.get(cellClass); + return hash == null ? 0 : hash.size(); } public int getGlobalCapacity(Cell cell) { diff --git a/core/src/com/protoevo/env/CurrentField.java b/core/src/com/protoevo/env/CurrentField.java new file mode 100644 index 0000000..f7c95fa --- /dev/null +++ b/core/src/com/protoevo/env/CurrentField.java @@ -0,0 +1,70 @@ +package com.protoevo.env; + +import com.badlogic.gdx.math.Vector2; + +import java.io.Serializable; + +/** + * Smooth 2D current field driving cells around the world. Derived as the + * curl of a scalar potential ψ, so the resulting flow is divergence-free + * (cells don't accumulate at "sinks"). Two superimposed travelling waves + * give a multi-eddy pattern that drifts slowly with sim time, so the + * flow pattern itself shifts on a long timescale — preventing protozoa + * from camping permanent dead spots. + * + * ψ(x, y, t) = sin(k₁x + ω₁t) cos(k₁y) + * + 0.5 · sin(k₂y + ω₂t) cos(k₂x) + * + * Velocity = curl(ψ): + * vx = -∂ψ/∂y = k₁ sin(k₁x + ω₁t) sin(k₁y) + * - 0.5 k₂ cos(k₂y + ω₂t) cos(k₂x) + * vy = ∂ψ/∂x = k₁ cos(k₁x + ω₁t) cos(k₁y) + * - 0.5 k₂ sin(k₂y + ω₂t) sin(k₂x) + * + * Applied as a Box2D *force* (not impulse) so cells feel drag against the + * flow and reach terminal velocity rather than being yeeted to infinity. + */ +public class CurrentField implements Serializable { + public static final long serialVersionUID = 1L; + + private float intensity; + private float k1, k2; // spatial frequencies + private float omega1, omega2; // temporal frequencies + + public CurrentField() { + this(1.5e-5f, 4f, 0.05f); + } + + /** + * @param intensity overall force scale (per-frame Box2D force) + * @param spatialScale characteristic eddy size in world units + * @param timeRate how fast the pattern drifts (radians of phase + * per sim-second of advected wave) + */ + public CurrentField(float intensity, float spatialScale, float timeRate) { + this.intensity = intensity; + // Primary eddy size + a half-scale companion at perpendicular + // orientation. Multi-scale gives the flow more visual interest + // and prevents the field from locking cells into one circulation. + this.k1 = 1f / Math.max(spatialScale, 1e-3f); + this.k2 = 2f * k1; + this.omega1 = timeRate; + this.omega2 = 0.7f * timeRate; + } + + /** Sample the current velocity at (x, y) at sim time t, into `out`. */ + public void sample(float x, float y, float t, Vector2 out) { + float a1 = k1 * x + omega1 * t; + float a2 = k2 * y + omega2 * t; + + float vx = (float) (k1 * Math.sin(a1) * Math.sin(k1 * y) + - 0.5f * k2 * Math.cos(a2) * Math.cos(k2 * x)); + float vy = (float) (k1 * Math.cos(a1) * Math.cos(k1 * y) + - 0.5f * k2 * Math.sin(a2) * Math.sin(k2 * x)); + + out.set(vx * intensity, vy * intensity); + } + + public float getIntensity() { return intensity; } + public void setIntensity(float v) { intensity = v; } +} diff --git a/core/src/com/protoevo/env/Environment.java b/core/src/com/protoevo/env/Environment.java index e3760cf..f595e40 100644 --- a/core/src/com/protoevo/env/Environment.java +++ b/core/src/com/protoevo/env/Environment.java @@ -53,6 +53,17 @@ public class Environment implements Serializable private final ChemicalSolution chemicalSolution; private final LightManager light; private final TimeManager timeManager; + private transient CurrentField currentField; + private transient Vector2 currentForce; + // Monotonically increasing lineage id counter. Each new cell that isn't + // a descendant of an existing one (initial spawn, manual injection) + // gets the next value; burst children inherit their parent's lineage id. + private long nextLineageId = 1L; + // Phylogeny records — keyed by cell id, persists past death so the + // tree view can walk parents. Bounded by `pruneDeadLineages()` which + // runs periodically. Public-ish via getLineageRecords() for the UI. + private final java.util.HashMap lineageRecords = + new java.util.HashMap<>(); private final List rocks = new ArrayList<>(); private final HashMap, Long> bornCounts = new HashMap<>(3); private final HashMap, Long> generationCounts = new HashMap<>(3); @@ -73,6 +84,18 @@ public class Environment implements Serializable private final ConcurrentHashMap> burstRequests = new ConcurrentHashMap<>(); private final Collection handledBurstRequests = new ConcurrentLinkedQueue<>(); private final CellDeadPredicate isDeadPredicate = new CellDeadPredicate(); + // Per-frame consumer for the parallel cell-update pass. Reused so each + // update doesn't allocate a fresh lambda holder; only the `delta` field + // changes per call. + private transient CellUpdateConsumer cellUpdateConsumer; + // Plant + meat delta accumulator. Their update() is skipped on + // intermediate substeps at high time dilation, and the accumulated dt + // is applied on the next "plants update" pass so the linear-in-delta + // terms (photosynthesis, growth, decay) integrate correctly. Non-linear + // edge events (plant-protozoa collision damage pulses) are sampled at + // the protozoa-pass cadence instead, which is fine because protozoa + // always update every step. + private transient float plantPendingDt = 0f; public Environment() { @@ -108,10 +131,50 @@ public Environment(SimulationSettings settings) { } public void createTransientObjects() { - cellsToAdd = new HashSet<>(); + // Concurrent set — parallel cell.update() workers can call + // registerToAdd() (e.g. burst-children path). Plain HashSet under + // concurrent writes corrupts its internal hash table — silent + // lost adds, NPEs deep in HashMap, intermittent crashes at high + // population. ConcurrentHashMap-backed key set has the same + // semantics for our usage and is lock-free for reads. + cellsToAdd = java.util.concurrent.ConcurrentHashMap.newKeySet(); chunks = new Chunks(); chunks.initialise(); - updateChunkAllocations(); + forceChunkRebuild(); + rebuildCurrentField(); + } + + private void rebuildCurrentField() { + // Defensive defaults: a save from before these params existed will + // deserialize EnvironmentSettings without them, and even with + // CompatibleFieldSerializer the field stays at its constructor + // default value (which only runs if Kryo invokes the constructor; + // not all instantiator strategies do). + float strength = (settings.env != null && settings.env.currentStrength != null) + ? settings.env.currentStrength.get() : 1.5e-5f; + float scale = (settings.env != null && settings.env.currentSpatialScale != null) + ? settings.env.currentSpatialScale.get() : 4f; + float rate = (settings.env != null && settings.env.currentTimeRate != null) + ? settings.env.currentTimeRate.get() : 0.05f; + currentField = new CurrentField(strength, scale, rate); + currentForce = new Vector2(); + } + + private void applyCurrents() { + if (currentField == null) + rebuildCurrentField(); + float intensity = settings.env.currentStrength.get(); + if (intensity <= 0f) + return; + // Keep CurrentField in sync with the settings so a live tweak via + // REPL or UI reflects immediately. Cheap — three field copies. + currentField.setIntensity(intensity); + float t = timeManager.getTimeElapsed(); + for (Cell cell : getCells()) { + Vector2 p = cell.getPos(); + currentField.sample(p.x, p.y, t, currentForce); + cell.getParticle().applyForce(currentForce); + } } public boolean hasStarted() { @@ -124,48 +187,151 @@ public void rebuildWorld() { physics.rebuildTransientFields(this); for (Cell cell : getCells()) cell.setEnvironment(this); - updateChunkAllocations(); + forceChunkRebuild(); } public void update(float delta) + { + update(delta, delta); + } + + /** + * Split-delta version for fast-forward substepping. + * + * @param physicsDelta delta used for the physics step + per-cell update. + * MUST be the small per-substep dt — running physics + * with a giant accumulated delta produces unstable + * behavior (cells tunneling through rocks, etc.). + * @param chemicalsDelta total accumulated delta for the chemical pass. + * Pass 0 to skip chemicals this call entirely; pass + * the same value as physicsDelta on the last substep + * to make the deposit reflect the time we *actually* + * advanced. The previous version skipped deposits on + * intermediate substeps, which made plants emit ~1/N + * as much chemical at N× speed — protozoa lost the + * gradient they navigate by, and populations starved. + */ + public void update(float physicsDelta, float chemicalsDelta) + { + update(physicsDelta, chemicalsDelta, true); + } + + /** + * @param plantsAndMeatThisStep if true, plant + meat cells run their full + * update this step. Otherwise only protozoa update — used by the fast- + * forward scheduler so we don't pay for 1500 plant updates on every + * substep when the per-render budget only fits one or two passes. + * Plants get a fat-delta update once per render frame instead. Since + * plant logic is linear in delta (photosynthesis, growth, decay) this + * integrates correctly; the only thing that loses fidelity is the + * plant–protozoa contact-death pulse, which evens out over many frames. + */ + public void update(float physicsDelta, float chemicalsDelta, + boolean plantsAndMeatThisStep) { hasStarted = true; settings = mySettings; + // Watchdog: time each sub-step. If anything exceeds the threshold, + // log it so we can see what's stalling the sim when it feels + // frozen. Threshold is 500ms — at 60fps GUI mode, anything over + // ~16ms drops frames, but 500ms is the threshold where the user + // would actually perceive the sim as "stuck". Numbers add up to + // total per-step time so the user can see which subsystem is hot. + long t0 = System.nanoTime(); for (Cell cell : getCells()) cell.getParticle().physicsUpdate(); + long t1 = System.nanoTime(); - timeManager.update(delta); - light.update(delta); + timeManager.update(physicsDelta); + light.update(physicsDelta); + long t2 = System.nanoTime(); - physics.step(delta); + applyCurrents(); + long t3 = System.nanoTime(); + + if (physicsDelta > 0f) + physics.step(physicsDelta); + long t4 = System.nanoTime(); + + handleCellUpdates(physicsDelta, plantsAndMeatThisStep); + long t5 = System.nanoTime(); - handleCellUpdates(delta); handleBirthsAndDeaths(); - updateChunkAllocations(); + long t6 = System.nanoTime(); + + updateChunkAllocations(physicsDelta); + pruneDeadLineages(physicsDelta); + long t7 = System.nanoTime(); physics.getJointsManager().flushJoints(); + long t8 = System.nanoTime(); - if (Environment.settings.enableChemicalField.get()) { - chemicalSolution.update(delta); + if (chemicalsDelta > 0f && Environment.settings.enableChemicalField.get()) { + chemicalSolution.update(chemicalsDelta); + } + long t9 = System.nanoTime(); + + long totalMs = (t9 - t0) / 1_000_000L; + if (totalMs > 500) { + System.out.printf( + "[watchdog] slow tick %dms physUpd=%dms light=%dms currents=%dms " + + "physStep=%dms cellUpd=%dms births=%dms chunks=%dms joints=%dms chem=%dms " + + "cells=%d%n", + totalMs, + (t1 - t0) / 1_000_000L, + (t2 - t1) / 1_000_000L, + (t3 - t2) / 1_000_000L, + (t4 - t3) / 1_000_000L, + (t5 - t4) / 1_000_000L, + (t6 - t5) / 1_000_000L, + (t7 - t6) / 1_000_000L, + (t8 - t7) / 1_000_000L, + (t9 - t8) / 1_000_000L, + cells.size()); } } + public void ensureAddedToEnvironment(Cell cell) { if (!cells.containsKey(cell.getId())) registerToAdd(cell); } - private void handleCellUpdates(float delta) { - getCells().parallelStream().forEach(new CellUpdateConsumer(delta)); + private void handleCellUpdates(float delta, boolean updatePlantsAndMeat) { + if (cellUpdateConsumer == null) + cellUpdateConsumer = new CellUpdateConsumer(delta); + cellUpdateConsumer.delta = delta; + // Plants/meat get caught-up delta on the steps where they actually run. + // On skipped steps we accumulate; on running steps they receive + // (current delta) + (sum of skipped deltas). + if (updatePlantsAndMeat) { + cellUpdateConsumer.plantDelta = delta + plantPendingDt; + plantPendingDt = 0f; + } else { + plantPendingDt += delta; + } + cellUpdateConsumer.updatePlantsAndMeat = updatePlantsAndMeat; + getCells().parallelStream().forEach(cellUpdateConsumer); } private void handleBirthsAndDeaths() { handledBurstRequests.clear(); + // Per-tick cap on burst processing. With engulf efficiency at 1.0, + // the entire population can hit splitRadius+health threshold in + // the same tick — leading to 500 simultaneous bursts, each creating + // 2-6 children with Box2D body construction, particle init, GRN + // cloning, lineage walks, and chunk allocation. That single tick + // can take seconds in real time, and the user experiences it as a + // freeze. Cap at 64/tick: any extra requests just sit in the queue + // for next tick. + int burstBudget = 64; for (Cell parent : burstRequests.keySet()) { + if (burstBudget <= 0) break; BurstRequest burstRequest = burstRequests.get(parent); if (hasBurstCapacity(parent, burstRequest.getCellType()) && burstRequest.canBurst()) { burstRequest.burst(); handledBurstRequests.add(parent); + burstBudget--; } } for (Cell parent : handledBurstRequests) @@ -174,13 +340,21 @@ private void handleBirthsAndDeaths() { flushEntitiesToAdd(); - for (Cell cell : getCells()) { - if (cell.isDead()) { - dispose(cell); - depositOnDeath(cell); - } - } - getCells().removeIf(isDeadPredicate); + // Single pass: dispose+deposit+remove for any dead cell. The previous + // version iterated the map twice (once to dispose/deposit, once to + // removeIf) — at large populations that's a measurable repeat scan. + // We also pull the cell out of `chunks` here so the spatial index + // stays consistent without needing a full clear-and-rebuild on every + // frame (see updateChunkAllocations comment). + getCells().removeIf(cell -> { + if (!cell.isDead()) + return false; + dispose(cell); + depositOnDeath(cell); + chunks.remove(cell); + recordDeath(cell); + return true; + }); } public void createRocks() { @@ -244,6 +418,32 @@ public void initialisePopulation() { buildSpawners(); + // Initial-viability seed. + // + // initialPlantSig (50): every plant gets this; every protozoa's + // plantReceptorKey is set to a copy → guaranteed full-rate + // plant feeding from frame 1. + // + // initialReceiving / initialPhagocytic (75 each): TWO INDEPENDENT + // random sequences. Every protozoa gets these two as its + // receiving and phagocytic receptors. Because they're + // independent random 75-mers, expected identity ≈ 5% << the + // 15% engulf threshold → no cell can eat any other at t=0 + // (no kin cannibalism on a fresh world). Mutation will drift + // lineages apart; predator-prey relationships only emerge + // when some lineage's phagocytic happens to drift toward + // another lineage's receiving — a real evolutionary event, + // not a freebie. + com.protoevo.biology.evolution.AminoAcidSequence initialPlantSig = + new com.protoevo.biology.evolution.AminoAcidSequence( + com.protoevo.biology.evolution.PlantSignatureTrait.LENGTH); + com.protoevo.biology.evolution.AminoAcidSequence initialReceiving = + new com.protoevo.biology.evolution.AminoAcidSequence( + com.protoevo.biology.evolution.ProtozoaSignatureTrait.LENGTH); + com.protoevo.biology.evolution.AminoAcidSequence initialPhagocytic = + new com.protoevo.biology.evolution.AminoAcidSequence( + com.protoevo.biology.evolution.ProtozoaSignatureTrait.LENGTH); + int nPlants = Environment.settings.worldgen.numInitialPlantPellets.get(); nPlants = Math.min(nPlants, chunks.getGlobalCapacity(PlantCell.class)); System.out.println("Creating population of " + nPlants + " plants..." ); @@ -254,6 +454,10 @@ public void initialisePopulation() { cell = Evolvable.createNew(PlantCell.class); else cell = new PlantCell(); + // Override the randomized signature with the shared seed so all + // initial plants are recognizable by the seeded protozoa. + cell.setSurfaceSignature( + new com.protoevo.biology.evolution.AminoAcidSequence(initialPlantSig)); cell.setEnvironmentAndBuildPhysics(this); findRandomPositionOrKillCell(cell); } @@ -264,6 +468,12 @@ public void initialisePopulation() { loadingStatus = "Spawning Protozoa"; for (int i = 0; i < nProtozoa; i++) { Protozoan p = Evolvable.createNew(Protozoan.class); + p.setPlantReceptorKey( + new com.protoevo.biology.evolution.AminoAcidSequence(initialPlantSig)); + p.setProtozoaReceivingReceptor( + new com.protoevo.biology.evolution.AminoAcidSequence(initialReceiving)); + p.setProtozoaPhagocyticReceptor( + new com.protoevo.biology.evolution.AminoAcidSequence(initialPhagocytic)); p.setEnvironmentAndBuildPhysics(this); findRandomPositionOrKillCell(p); } @@ -344,6 +554,13 @@ public Vector2 randomPosition(float entityRadius) { public void tryAdd(Cell cell) { add(cell); + // Assign a founder lineage id if this cell wasn't already tagged + // (initial-spawn cells, or any future case where we inject cells + // without a parent). Burst children get their parent's id set by + // BurstRequest before they hit this path. + if (cell.getLineageId() == 0L) + cell.setLineageId(nextLineageId++); + recordBirth(cell); bornCounts.put(cell.getClass(), bornCounts.getOrDefault(cell.getClass(), 0L) + 1); generationCounts.put(cell.getClass(), @@ -351,6 +568,83 @@ public void tryAdd(Cell cell) { cell.getGeneration())); } + public long allocateLineageId() { + return nextLineageId++; + } + + private void recordBirth(Cell cell) { + // Only track *protozoa* in the phylogeny. Plants and meat exist in + // such churn (a meat pellet's "lineage" is meaningless; plants + // barely evolve and 1500 of them swamp the tree with single-cell + // chains) that including them turns the tree into a 10k-leaf + // hairball nobody can read. The tree is meant to show interesting + // evolutionary divergence — that's a protozoa-only concept here. + if (!(cell instanceof Protozoan)) + return; + LineageRecord r = new LineageRecord(); + r.id = cell.getId(); + r.parentId = cell.getParentId(); + r.generation = cell.getGeneration(); + r.birthTime = timeManager.getTimeElapsed(); + r.deathTime = -1f; + r.aliveDescendants = 1; + r.cellType = 0; // Protozoan + lineageRecords.put(r.id, r); + // Propagate the +1 alive-descendants count up the parent chain so + // the renderer can prioritise lineages with the most living + // descendants without scanning the whole tree every frame. + long pid = r.parentId; + // Same safety cap as recordDeath — a cycle in parentId would hang + // every birth path and lock the sim. 4096 is far past any real depth. + int hops = 0; + while (pid != 0L && hops++ < 4096) { + LineageRecord p = lineageRecords.get(pid); + if (p == null) break; + p.aliveDescendants++; + pid = p.parentId; + } + } + + private void recordDeath(Cell cell) { + LineageRecord r = lineageRecords.get(cell.getId()); + if (r == null) return; // plants/meat were never tracked + r.deathTime = timeManager.getTimeElapsed(); + r.aliveDescendants--; // self + long pid = r.parentId; + // Safety cap: if a parentId chain ever forms a cycle (shouldn't, + // but a corrupted save or simultaneous-lineage-id collision could + // in principle create one), this loop would hang the death path + // and stall the entire sim. 4096 generations is way past any + // realistic chain depth. + int hops = 0; + while (pid != 0L && hops++ < 4096) { + LineageRecord p = lineageRecords.get(pid); + if (p == null) break; + p.aliveDescendants--; + pid = p.parentId; + } + } + + private float lineagePruneTimer = 0f; + /** Drop lineage records whose entire subtree died out long enough ago + * that they're no longer interesting for the tree view. Keeps the + * record map bounded — without this it grows with every birth. */ + private void pruneDeadLineages(float delta) { + lineagePruneTimer += delta; + if (lineagePruneTimer < 30f) return; + lineagePruneTimer = 0f; + float now = timeManager.getTimeElapsed(); + final float keepDeadFor = 120f; // sim-sec of grace + lineageRecords.values().removeIf(r -> + r.aliveDescendants <= 0 + && r.deathTime > 0f + && (now - r.deathTime) > keepDeadFor); + } + + public java.util.Map getLineageRecords() { + return lineageRecords; + } + public void add(Cell cell) { cells.put(cell.getId(), cell); chunks.add(cell); @@ -370,10 +664,31 @@ public int getCount(Class cellClass) { return chunks.getLocalCount(cellClass); } - public void updateChunkAllocations() { + // Full chunk rebuild is expensive (clear all 1200 sets + re-add every + // cell). Births/deaths are now tracked incrementally via chunks.add / + // chunks.remove, so the only thing a full rebuild catches is cells that + // have *moved* between chunks. Cells take many frames to traverse a chunk, + // so stale-by-a-second counts are fine for capacity checks. We still do a + // periodic full rebuild to clear any drift. Transient so adding this + // field doesn't break older saves. + private transient float chunkRebuildTimer = 0f; + private static final float CHUNK_REBUILD_INTERVAL = 0.5f; + + public void updateChunkAllocations(float delta) { + chunkRebuildTimer += delta; + if (chunkRebuildTimer < CHUNK_REBUILD_INTERVAL) + return; + chunkRebuildTimer = 0f; + chunks.clear(); + for (Cell cell : getCells()) + chunks.allocate(cell); + } + + public void forceChunkRebuild() { chunks.clear(); for (Cell cell : getCells()) chunks.allocate(cell); + chunkRebuildTimer = 0f; } private void dispose(Cell e) { @@ -526,6 +841,52 @@ public int numberOfProtozoa() { return getCount(Protozoan.class); } + // Per-cause death counters since last reset. Used by the homeostat tick + // to print "what's killing cells this window". + // + // MUST be a thread-safe map: Cell.kill is called from inside the + // parallelStream cell-update pass, so recordDeath fires concurrently + // from worker threads. The previous HashMap.merge() was a freeze risk + // — concurrent merges into a non-thread-safe HashMap can spin in + // internal bucket traversal and lock up the worker pool, which is + // exactly what happens in turbo mode where deaths per wall-second + // spike. ConcurrentHashMap.merge() is lock-free per bucket. + // + // Transient because the counts are diagnostic, not save state. + private transient java.util.concurrent.ConcurrentHashMap + recentProtozoaDeaths = new java.util.concurrent.ConcurrentHashMap<>(); + private transient java.util.concurrent.ConcurrentHashMap + recentPlantDeaths = new java.util.concurrent.ConcurrentHashMap<>(); + + public void recordDeath(Cell cell, com.protoevo.biology.CauseOfDeath cause) { + if (recentProtozoaDeaths == null) recentProtozoaDeaths = new java.util.concurrent.ConcurrentHashMap<>(); + if (recentPlantDeaths == null) recentPlantDeaths = new java.util.concurrent.ConcurrentHashMap<>(); + java.util.concurrent.ConcurrentHashMap target = + cell instanceof Protozoan ? recentProtozoaDeaths + : cell instanceof com.protoevo.biology.cells.PlantCell ? recentPlantDeaths + : null; + if (target != null) + target.merge(cause, 1, Integer::sum); + } + + public java.util.Map drainProtozoaDeaths() { + java.util.Map snap = + recentProtozoaDeaths == null + ? java.util.Collections.emptyMap() + : new java.util.HashMap<>(recentProtozoaDeaths); + if (recentProtozoaDeaths != null) recentProtozoaDeaths.clear(); + return snap; + } + + public java.util.Map drainPlantDeaths() { + java.util.Map snap = + recentPlantDeaths == null + ? java.util.Collections.emptyMap() + : new java.util.HashMap<>(recentPlantDeaths); + if (recentPlantDeaths != null) recentPlantDeaths.clear(); + return snap; + } + public long getGeneration() { return generationCounts.getOrDefault(Protozoan.class, 0L); } @@ -646,17 +1007,26 @@ public String getSimulationName() { } public static class CellUpdateConsumer implements Serializable, Consumer { - private float delta; + float delta; + float plantDelta; + boolean updatePlantsAndMeat = true; public CellUpdateConsumer() {} public CellUpdateConsumer(float delta) { this.delta = delta; + this.plantDelta = delta; } @Override public void accept(Cell cell) { - cell.update(delta); + if (cell instanceof Protozoan) { + cell.update(delta); + return; + } + if (!updatePlantsAndMeat) + return; + cell.update(plantDelta); } } diff --git a/core/src/com/protoevo/env/LineageRecord.java b/core/src/com/protoevo/env/LineageRecord.java new file mode 100644 index 0000000..2330a01 --- /dev/null +++ b/core/src/com/protoevo/env/LineageRecord.java @@ -0,0 +1,35 @@ +package com.protoevo.env; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +/** + * Phylogeny entry for a single cell that ever lived. Kept on the + * Environment so the lineage tree view can walk parent→child even after + * the parent has died. Records are culled once the entire descendant + * subtree has died out. + */ +public class LineageRecord implements Serializable { + public static final long serialVersionUID = 1L; + + public long id; + public long parentId; // 0 if a founder (initial spawn) + public int generation; + public float birthTime; + public float deathTime = -1f; // -1 ≡ still alive + public int aliveDescendants = 1; // self + living descendants + // Per-cell-class enum: 0=Protozoan, 1=Plant, 2=Meat, -1=other. + public byte cellType = -1; + // Children list — populated lazily by the renderer when it walks the + // tree, to avoid maintaining parent→children invariants on every + // birth/death (which is hot path). + public transient List childrenScratch; + + public boolean isAlive() { return deathTime < 0f; } + public List children() { + if (childrenScratch == null) + childrenScratch = new ArrayList<>(0); + return childrenScratch; + } +} diff --git a/core/src/com/protoevo/env/serialization/KryoSerialization.java b/core/src/com/protoevo/env/serialization/KryoSerialization.java index 79a6bf5..dc2e47c 100644 --- a/core/src/com/protoevo/env/serialization/KryoSerialization.java +++ b/core/src/com/protoevo/env/serialization/KryoSerialization.java @@ -3,8 +3,10 @@ import com.badlogic.gdx.math.Vector2; import com.esotericsoftware.kryo.Kryo; import com.esotericsoftware.kryo.Serializer; +import com.esotericsoftware.kryo.SerializerFactory; import com.esotericsoftware.kryo.io.Input; import com.esotericsoftware.kryo.io.Output; +import com.esotericsoftware.kryo.serializers.CompatibleFieldSerializer; import com.protoevo.biology.*; import com.protoevo.biology.cells.*; import com.protoevo.biology.evolution.*; @@ -38,8 +40,33 @@ public class KryoSerialization { public static boolean WARN_UNREGISTERED_CLASSES = false; + // Kryo isn't thread-safe, but the simulation only ever serializes from + // one thread at a time (auto-saves go through Simulation.saveOnOtherThread + // which guards with `busyOnOtherThread`). Caching this instance turns the + // ~150-class registration call into a one-time setup cost per JVM rather + // than repeating it on every save/load. On large worlds this was several + // hundred ms of pure registration overhead per save. + private static Kryo cachedKryo; + public static Kryo getKryo() { + if (cachedKryo != null) + return cachedKryo; Kryo kryo = new Kryo(); + // Use CompatibleFieldSerializer for everything by default. Default + // FieldSerializer in Kryo 5 matches fields by *position*, so adding + // ANY non-transient field to a serialised class permanently breaks + // every existing save. CompatibleFieldSerializer matches by *name* + // and skips fields that no longer exist / fills missing ones with + // the class's default-constructed value. The tradeoff is ~10–20 + // bytes of overhead per field per object; that's worth it given + // how often we're iterating on the data model right now. + // + // Old saves written with the default FieldSerializer cannot be + // read by CompatibleFieldSerializer (different on-disk format). + // That's an unavoidable one-time break — but from this point on, + // any new field addition won't corrupt existing saves. + kryo.setDefaultSerializer(CompatibleFieldSerializer.class); + kryo.register(Environment.class); kryo.register(Cell.class); kryo.register(MultiCellStructure.class); @@ -197,6 +224,7 @@ public static Kryo getKryo() { kryo.setRegistrationRequired(false); kryo.setWarnUnregisteredClasses(WARN_UNREGISTERED_CLASSES); + cachedKryo = kryo; return kryo; } diff --git a/core/src/com/protoevo/env/serialization/Serialization.java b/core/src/com/protoevo/env/serialization/Serialization.java index abf003b..bfb71a2 100644 --- a/core/src/com/protoevo/env/serialization/Serialization.java +++ b/core/src/com/protoevo/env/serialization/Serialization.java @@ -79,9 +79,19 @@ public static void saveEnvironment(Environment env, String filename) } public static Environment reloadEnvironment(String filename) { + long t0 = System.currentTimeMillis(); + System.out.println("[load] deserializing " + filename + "/environment.dat ..."); Environment env = deserialize(filename + "/environment.dat", Environment.class); + long tDeser = System.currentTimeMillis(); + System.out.printf("[load] deserialize: %dms (cells=%d)%n", + tDeser - t0, env.getCells().size()); env.createTransientObjects(); + long tTrans = System.currentTimeMillis(); + System.out.printf("[load] createTransientObjects: %dms%n", tTrans - tDeser); env.rebuildWorld(); + long tWorld = System.currentTimeMillis(); + System.out.printf("[load] rebuildWorld: %dms%n", tWorld - tTrans); + System.out.printf("[load] total: %dms%n", tWorld - t0); return env; } diff --git a/core/src/com/protoevo/physics/JointsManager.java b/core/src/com/protoevo/physics/JointsManager.java index 3733e05..f976962 100644 --- a/core/src/com/protoevo/physics/JointsManager.java +++ b/core/src/com/protoevo/physics/JointsManager.java @@ -111,12 +111,14 @@ protected void registerJoining(Joining joining) { } public void requestJointRemoval(long id) { + // Eager-deregister was duplicating with the flush-time deregister + // in Box2DJointsManager.handleStaleJoints, so each cell-side + // joining callback fired twice — wasted CPU and side-effect + // double-counting. Just queue the removal; the flush pass will + // handle deregistration once. if (!jointRemovalRequests.contains(id)) { jointRemovalRequests.add(id); } - if (joinings.containsKey(id)) { - deregisterJoining(joinings.get(id)); - } } public boolean areJoined(Particle p1, Particle p2) { diff --git a/core/src/com/protoevo/physics/Particle.java b/core/src/com/protoevo/physics/Particle.java index db7e000..a6dbc01 100644 --- a/core/src/com/protoevo/physics/Particle.java +++ b/core/src/com/protoevo/physics/Particle.java @@ -83,6 +83,12 @@ public float getArea() { public abstract void setRangedInteractionRadius(float radius); + /** Override the default angular damping for this particle. Plants in + * particular want much lower damping so collision torque actually + * rotates them — otherwise their spike angle is locked to their + * spawn orientation forever, making spike defence purely random. */ + public abstract void setAngularDamping(float damping); + public void setCanInteractAtRange() { rangedInteractionsEnabled = true; } diff --git a/core/src/com/protoevo/physics/SpatialHash.java b/core/src/com/protoevo/physics/SpatialHash.java index db2f9d4..98128d2 100644 --- a/core/src/com/protoevo/physics/SpatialHash.java +++ b/core/src/com/protoevo/physics/SpatialHash.java @@ -6,10 +6,9 @@ import java.io.Serializable; import java.util.Collection; import java.util.Collections; -import java.util.Comparator; +import java.util.HashSet; import java.util.Iterator; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentSkipListSet; public class SpatialHash implements Serializable, Iterable> { @@ -65,33 +64,52 @@ public boolean isFull(Vector2 worldPos) { } public int getChunkX(float x) { - return (int) (x / chunkSize); + // floor, not truncate. (int) casts toward zero, so x in (-chunkSize, + // chunkSize) all mapped to chunk 0 — that chunk had 4× area and + // 4× population pressure compared to its neighbours, distorting + // capacity checks across the origin. + return (int) Math.floor(x / chunkSize); } public int getChunkY(float y) { - return (int) (y / chunkSize); + return (int) Math.floor(y / chunkSize); } private int getChunkIndex(int i, int j) { - return i * resolution + j; + // Was: `i * resolution + j` — collides for any negative j because j + // can be -resolution..resolution while the formula expects j in + // [0, resolution). Example: (-5, 5) and (-4, -5) both hashed to -45. + // Cells from those distinct chunks shared one HashSet, breaking + // capacity / neighbour queries across the negative quadrant. + // + // Shift both axes by `resolution` and use stride (2*resolution + 1). + // Combined with the floor fix above, the world centered on origin + // has well-defined per-cell buckets out to ±resolution chunks. + int span = 2 * resolution + 1; + return (i + resolution) * span + (j + resolution); } public boolean add(T t, int i, int j) { int idx = getChunkIndex(i, j); - Collection chunk; - if (!chunkContents.containsKey(idx)) { - chunk = new ConcurrentSkipListSet<>(Comparator.comparingInt(Object::hashCode)); - chunkContents.put(idx, chunk); - } else { - chunk = chunkContents.get(idx); - } + // ConcurrentSkipListSet was overkill — chunk writes only happen on + // the main thread (Chunks.add via flushEntitiesToAdd / + // updateChunkAllocations), and reads during cell.update parallelStream + // are non-mutating. Plain HashSet has O(1) size() instead of skip + // list's O(n) traversal — getGlobalCount and capacity checks fire + // dozens of times per frame so this matters. + // Atomic put-if-absent. Old check-then-act would orphan one of two + // HashSets if two threads ever called add() with the same idx + // simultaneously (no concurrent writes today, but safer to make + // the lazy-init thread-safe so a future caller can't accidentally + // corrupt a chunk into an unreachable orphan). + Collection chunk = chunkContents.computeIfAbsent(idx, k -> new HashSet<>(8)); if (chunk.size() >= maxObjectsPerChunk) return false; - chunk.add(t); - size++; + if (chunk.add(t)) + size++; return true; } @@ -104,6 +122,24 @@ public boolean add(T t, Vector2 pos) { return add(t, x, y); } + public boolean remove(T t, Vector2 pos) { + int idx = getChunkIndex(getChunkX(pos.x), getChunkY(pos.y)); + Collection chunk = chunkContents.get(idx); + if (chunk != null && chunk.remove(t)) { + size--; + return true; + } + // Fallback: cell may have been counted in a different chunk than its + // current pos (it moved since last add). Linear scan — uncommon path. + for (Collection any : chunkContents.values()) { + if (any.remove(t)) { + size--; + return true; + } + } + return false; + } + public boolean add(T t, Vector2[] bounds) { int x1 = getChunkX(bounds[0].x); int y1 = getChunkY(bounds[0].y); diff --git a/core/src/com/protoevo/physics/box2d/Box2DCollisionHandler.java b/core/src/com/protoevo/physics/box2d/Box2DCollisionHandler.java index 03d898d..8cb3bd9 100644 --- a/core/src/com/protoevo/physics/box2d/Box2DCollisionHandler.java +++ b/core/src/com/protoevo/physics/box2d/Box2DCollisionHandler.java @@ -62,8 +62,13 @@ public void endContact(Contact contact) { if (bodyA.getUserData() instanceof Box2DParticle) ((Box2DParticle) bodyA.getUserData()).endContact(bodyB.getUserData()); + // Was passing bodyB.getUserData() here — i.e. each particle telling + // itself "I ended contact with myself". That removed nothing, leaving + // stale contacts in particle B's set until the distance-based GC in + // removeCollision eventually caught them. At scale this leaked into + // measurable overhead on every cell.update. if (bodyB.getUserData() instanceof Box2DParticle) - ((Box2DParticle) bodyB.getUserData()).endContact(bodyB.getUserData()); + ((Box2DParticle) bodyB.getUserData()).endContact(bodyA.getUserData()); if (fixtureA.isSensor() && bodyA.getUserData() instanceof Box2DParticle) { diff --git a/core/src/com/protoevo/physics/box2d/Box2DParticle.java b/core/src/com/protoevo/physics/box2d/Box2DParticle.java index 5cd9f0d..535d39b 100644 --- a/core/src/com/protoevo/physics/box2d/Box2DParticle.java +++ b/core/src/com/protoevo/physics/box2d/Box2DParticle.java @@ -226,6 +226,12 @@ public void setRangedInteractionRadius(float radius) { interactionRadius = radius; } + @Override + public void setAngularDamping(float damping) { + if (body != null) + body.setAngularDamping(damping); + } + public void addInteractingObject(Object object) { interactionObjects.add(object); } @@ -243,8 +249,27 @@ public void interact(List interactions) {} public void onCollision(Collision collision, Box2DParticle other) { if (other.isPointInside(getPos())) { - kill(CauseOfDeath.SUFFOCATION); - return; + // Suffocation = "stuck inside another body". Originally fired on + // every same-type contact too, which meant a plant burst spawning + // 5 children clustered around the parent killed several of the + // siblings on spawn (their centers landed inside each other's + // circles before physics could push them apart). At cap-level + // plant populations this was thousands of false deaths/sec. + // + // Suffocate only when the OTHER cell is a different type AND + // substantially larger — i.e. the kind of "swallowed by a much + // bigger cell" case that the death actually models. Same-type + // overlap (plant–plant, protozoa–protozoa) gets resolved by + // Box2D contact forces instead. + Object myUser = getUserData(); + Object otherUser = other.getUserData(); + boolean sameType = + myUser != null && otherUser != null + && myUser.getClass() == otherUser.getClass(); + if (!sameType && other.getRadius() > getRadius() * 1.5f) { + kill(CauseOfDeath.SUFFOCATION); + return; + } } if (getOther(collision) != null) @@ -270,8 +295,34 @@ public Object getOther(Collision collision) { return collision.objB; } + // Cached shape state — Box2D reshapes (and recomputes mass/inertia) + // every time you call setRadius even when the value didn't change. At + // 2500 cells × every substep that's a meaningful waste; we skip the + // call when the radius is the same as last frame. Transient because + // these are pure runtime caches — saved games should not preserve them + // or older save formats break on schema mismatch. + private transient float lastDynamicRadius = -1f; + private transient float lastSensorRadius = -1f; + private transient float lastDampingFactor = -1f; + public void physicsUpdate() { if (body != null) { + boolean hasQueuedForce = forceToApply.len2() > 0 + || impulseToApply.len2() > 0 + || torqueToApply != 0; + // Sleeping body with no pending forces: pos/vel/angle haven't + // changed since the last wake, and there's nothing to commit. + // Skipping the whole body-update path for sleeping cells (mostly + // plants) is a major fast-forward win — Box2D method calls + // dominate physicsUpdate cost otherwise. + if (!body.isAwake() && !hasQueuedForce) { + torqueToApply = 0; + impulseToApply.set(0, 0); + if (!contacts.isEmpty()) + contacts.removeIf(this::removeCollision); + return; + } + if (forceToApply.len2() > 0) body.applyForceToCenter(forceToApply, true); if (impulseToApply.len2() > 0) @@ -282,22 +333,35 @@ public void physicsUpdate() { vel.set(body.getLinearVelocity()); pos.set(body.getPosition()); angle = body.getAngle(); - body.setLinearDamping(getDampeningFactor() * Environment.settings.env.fluidDragDampening.get()); + + float damping = getDampeningFactor() * Environment.settings.env.fluidDragDampening.get(); + if (damping != lastDampingFactor) { + body.setLinearDamping(damping); + lastDampingFactor = damping; + } if (getSpeed() < getRadius() / 50f) { body.setLinearVelocity(0, 0); body.setAwake(false); } - dynamicsFixture.getShape().setRadius((float) radius); - if (rangedInteractionsEnabled && interactionRadius > getRadius()) + float r = (float) radius; + if (r != lastDynamicRadius) { + dynamicsFixture.getShape().setRadius(r); + lastDynamicRadius = r; + } + if (rangedInteractionsEnabled && interactionRadius > r + && interactionRadius != lastSensorRadius) { sensorFixture.getShape().setRadius(interactionRadius); + lastSensorRadius = interactionRadius; + } } torqueToApply = 0; impulseToApply.set(0, 0); - contacts.removeIf(this::removeCollision); + if (!contacts.isEmpty()) + contacts.removeIf(this::removeCollision); } private boolean removeCollision(Collision collision) { diff --git a/core/src/com/protoevo/settings/CellSettings.java b/core/src/com/protoevo/settings/CellSettings.java index 66467e6..62ec7cc 100644 --- a/core/src/com/protoevo/settings/CellSettings.java +++ b/core/src/com/protoevo/settings/CellSettings.java @@ -6,19 +6,148 @@ public class CellSettings extends Settings { "The rate at which complex molecules decay.", 1e-6f ); + // Monotonic selection-pressure exponent ratcheted by the homeostat. + // PhagocyticReceptor.engulf uses match^(1 + this) as the digestion + // efficiency, so: + // pressure=0: efficiency = match¹ (linear, very forgiving — 60% match + // gives 60% digestion rate, lineages with mediocre keys + // can still feed). + // pressure=1: efficiency = match² (current "old" behavior — moderate + // selection). + // pressure=2: efficiency = match³ (strict — partial matches are + // heavily penalized; lineages that can't keep up with + // plant signature drift starve). + // pressure=3: match⁴ (only near-perfect matches feed at full rate). + // + // The ratchet in Simulation.maybeRatchetSelection only goes UP, only + // when population is at or above target AND derivative is small (stable). + // Starts at 0 so the world is forgiving early-game; the difficulty + // climbs as the lineages prove they can handle it. There's a one-minute + // sim-time delay between steps so a transient stable patch can't + // suddenly multiply the pressure. + public Parameter selectionPressureExponent = new Parameter<>( + "Selection Pressure Exponent", + "Additional exponent on receptor match for digestion efficiency. " + + "Ratcheted up monotonically by the homeostat when population is stable.", + 0f); + + // Floor on engulf digestion efficiency. When 1.0, the receptor-match + // system is effectively OFF — every engulf delivers full digestion + // regardless of signature, so cells can thrive on the basic engulf + // mechanic alone. As the homeostat ratchets this DOWN over time, the + // floor approaches the original 0.02 hard minimum, and lineages whose + // signatures don't track plant drift lose efficiency. This is what + // the user means by "gradual ramp": start the world forgiving, only + // make receptor specialization matter once the population is stable + // enough to handle it. + public Parameter engulfBaseEfficiency = new Parameter<>( + "Engulf Base Efficiency", + "Floor on engulf digestion efficiency (1.0 = receptor system effectively off). " + + "Ratcheted DOWN by the homeostat alongside the MIN_RUN ratchet.", + 1.0f); + + // Engulf MIN_RUN — the BINARY gate, separate from the analog efficiency + // curve above. A receptor must share a contiguous run of at least + // MIN_RUN identical residues with the prey's signature to even attempt + // engulfing. If MIN_RUN is too high relative to what fresh sequences + // achieve, no engulfs happen — and then the selectionPressureExponent + // ratchet never fires either (it only runs when population is stable + // and ≥ target, which requires successful predation in the first place). + // + // So MIN_RUN itself needs to start LOW (easy clear) and ratchet UP only + // when the population can sustain the current difficulty. Mirror of the + // exponent ratchet: monotonic, +1 step every RATCHET_INTERVAL when + // population is ≥ target and stable, capped well below sequence length. + // + // Initial values: + // plant=0: gate off for plants (the design intent is that cells nibble + // and engulf plants freely; ratchet phases in plant-receptor + // selection as the world stabilizes). + // protozoa=12: ALWAYS non-zero. Kin-cannibalism is meant to be HARD + // even at game start — without a floor, fresh random-receptor + // protozoa would mutually annihilate within seconds and the + // species would never get off the ground. 12 of 75 (16%) means + // random pairs effectively never match (chance of a 12-run by + // coincidence ≈ 64 × (1/20)^12 ≈ 4e-15), so only lineages that + // have actively co-evolved toward predator-specialization can + // cannibalize. The ratchet then climbs from there toward the + // cap, never below. + // + // Caps prevent the ratchet from driving evolution to impossibility: + // plant cap=15 (30% of length), protozoa cap=20 (~27% of length). + public Parameter plantEngulfMinRun = new Parameter<>( + "Plant Engulf Min Run", + "Minimum contiguous receptor-match length required to engulf a plant. " + + "Starts at 0 (gate off). Ratcheted up by the homeostat when population is stable.", + 0); + public Parameter protozoaEngulfMinRun = new Parameter<>( + "Protozoa Engulf Min Run", + "Minimum contiguous receptor-match length required to engulf a protozoan. " + + "Always non-zero — prevents free kin-cannibalism at game start. " + + "Ratcheted up by the homeostat when population is stable.", + 12); + public Parameter energyDecayRate = new Parameter<>( "Energy Decay Rate", - "The rate at which energy storage decays.", - .05f + "Fractional energy decay coefficient. Effective rate is " + + "delta × this × (radius/minRadius) × energyAvailable.", + // 0.005: at radius/minR=2.5 and full energy this gives ~1.25%/sec + // decay, i.e. ~55s half-life for a full cell at rest. Cells must + // feed regularly to stay topped up; sitting still drains them. + // Previous interpretation was a flat J/sec which made decay + // essentially negligible for any cell over a few seconds old. + .005f + ); + // Starvation damage: when a cell's energy falls below + // `starvationThresholdFraction` × its energy cap, it begins to lose + // health at `starvationDeathRate` × the deficit fraction per sim second. + // Without this, an energy-depleted cell just stops growing and repairing + // but never dies — so the homeostat's "decay rate" lever couldn't + // actually reduce the population, no matter how high it pushed decay. + // Cells could indefinitely recycle each other via meat without ever + // hitting an energy-driven death pathway. + // + // Threshold is 5% of capacity — newborns spawn at 10% (startingEnergy=50, + // cap=500) so they have a small buffer before starvation begins. Death + // rate of 0.2/s means a fully-starved cell dies in ~5 sim seconds, fast + // enough that the homeostat's decay-rate lever has real authority over + // an out-of-target population. + public final Parameter starvationThresholdFraction = new Parameter<>( + "Starvation Threshold Fraction", + "Energy/capacity ratio below which cells start losing health to starvation.", + 0.05f ); + public final Parameter starvationDeathRate = new Parameter<>( + "Starvation Death Rate", + "Health lost per second of fully-starved time (linearly scaled by deficit).", + // 0.2 → 0.05. At 0.2, a fully-starved cell dies in ~5 sim-seconds — + // not enough time for a fresh cohort to find a chemical-drip + // halo in plant-poor regions. Repeated diagnostic runs showed + // ~250 STARVATION deaths in the first 5 sim-seconds of every + // launch: cells spawned, drained their initial energy reserve, + // and were killed before diffusion had time to spread plant + // chemicals across the gaps. 0.05 quadruples the grace period + // (full starvation kills in ~20 sim-sec) so a cell that + // wanders into a plant-poor pocket has a real chance to drift + // back to chemistry before health collapses. + 0.05f + ); + // Dropped 10× (1e-3 → 1e-4). The drip channel was sustaining cells + // without ANY receptor match, so plants could outevolve protozoa keys + // and the cells would just nibble chemicals indefinitely instead of + // adapting. With drips an order of magnitude smaller, drip alone is a + // bare survival trickle — cells must engulf to thrive, and engulf + // rewards receptor-match² (see PhagocyticReceptor). This creates + // genuine selection pressure for keys that track evolving plant + // signatures. public final Parameter chemicalExtractionPlantConversion = new Parameter<>( "Chemical Extraction Plant Conversion", "The amount of food extracted from plant matter in the chemical solution.", - 1e-3f); + 1e-4f); public final Parameter chemicalExtractionMeatConversion = new Parameter<>( "Chemical Extraction Meat Conversion", "The amount of food extracted from meat matter in the chemical solution.", - 1e-4f); + 1e-5f); public final Parameter chemicalExtractionFactor = new Parameter<>( "Chemical Extraction Factor", "The amount to dilute the chemical solution by when extracting food.", @@ -35,10 +164,15 @@ public class CellSettings extends Settings { "Digestion Factor", "Controls how quickly cells can digest food.", 20f); + // Dropped from 0.01 → 0.003. Combined with the activity-heat generator + // (below), the old rate killed actively-feeding cells via hyperthermia + // faster than they could repair, even with full energy reserves. + // Eating literally cooked them. 0.003 lets temperature mismatch still + // matter for selection without turning every meal into a death event. public final Parameter temperatureDeathRate = new Parameter<>( "Temperature Death Rate", "Rate at which a cell looses health when outside its temperature tolerance range.", - .01f); + .003f); public final Parameter minTemperatureTolerance = new Parameter<>( "Min Temperature Tolerance", "Minimum temperature tolerance (+/- degrees before suffering adverse effects).", @@ -51,10 +185,20 @@ public class CellSettings extends Settings { "Temperature Tolerance Energy Cost", "Energy cost per degree of temperature tolerance per unit time.", 1 / 100f); + // Dropped 0.5 → 0.1. Repeated diagnostic runs showed HYPERTHERMIA + // becoming the dominant death cause once the population hits target + // (~25–35 deaths/interval, vs ~5 from starvation). The 0.5 rate let + // cells equilibrate at envTemp + 5°C from passive movement+GRN + // activity alone, putting them above the +3°C tolerance window at + // baseline. 0.1 brings the equilibrium to ~envTemp + 1°C — well + // inside tolerance, so only cells that are EXTREMELY active (large + // burst of digestion, repair, growth all at once) flirt with the + // overheat band. Combat / hunger pressure still scale with activity, + // just at a sane multiplier. public final Parameter activityHeatGeneration = new Parameter<>( "Cell Temperature Death Rate", "Heat generated per unit activity per unit time.", - 3f); + 0.1f); public final Parameter basicParticleMassDensity = new Parameter<>( "Base Particle Mass Density", "The mass density of a basic particle.", @@ -62,7 +206,16 @@ public class CellSettings extends Settings { public final Parameter startingAvailableCellEnergy = new Parameter<>( "Starting Available Cell Energy", "Starting amount of energy available to cells.", - 1f); + // 50 → 250. The cap is 500 × radius / minRadius. Cells spawn at + // radii in [0.02, 0.06], giving caps of [500, 1500] and 5% + // starvation thresholds in [25, 75]. With starting=50, all but + // the smallest spawns were BELOW threshold from t=0 — cells took + // starvation damage before they could reach a plant, causing + // ~50 deaths/sec in the first 5 sim-seconds of every run. At 250 + // even the largest spawn (cap=1500, threshold=75) starts comfortably + // above the threshold (250 > 75), so cells have time to forage + // before metabolism + decay drain them past the danger line. + 250f); public final Parameter energyCapFactor = new Parameter<>( "Energy Cap Factor", "Maximum energy a cell can have at the minimum size.", diff --git a/core/src/com/protoevo/settings/EnvironmentSettings.java b/core/src/com/protoevo/settings/EnvironmentSettings.java index 50e3473..4c041db 100644 --- a/core/src/com/protoevo/settings/EnvironmentSettings.java +++ b/core/src/com/protoevo/settings/EnvironmentSettings.java @@ -13,6 +13,25 @@ public class EnvironmentSettings extends Settings { "Fluid Drag Dampening", "Controls the viscosity of the fluid.", 10f); + // Currents — a slowly-drifting flow field that pushes cells around the + // world. Without this, populations cluster permanently at the spot + // where they happened to first thrive; with it, plants drift into new + // regions and protozoa get carried away from saturated patches, so + // multiple competing population centres can coexist. Strength is the + // Box2D force scale per cell per substep — values around 1e-5 give a + // visible but not overwhelming drift. + public final Parameter currentStrength = new Parameter<>( + "Current Strength", + "Force scale of the divergence-free water current pushing cells around.", + 1.5e-5f); + public final Parameter currentSpatialScale = new Parameter<>( + "Current Spatial Scale", + "Characteristic eddy size in world units.", + 4f); + public final Parameter currentTimeRate = new Parameter<>( + "Current Time Rate", + "How fast the current pattern drifts (rad/sim-sec).", + 0.05f); public final Parameter voidDamagePerSecond = new Parameter<>( "Void Damage Per Second", "Factor controlling how much damage being outside the environment does.", diff --git a/core/src/com/protoevo/settings/EvolutionSettings.java b/core/src/com/protoevo/settings/EvolutionSettings.java index c78580f..d018b47 100644 --- a/core/src/com/protoevo/settings/EvolutionSettings.java +++ b/core/src/com/protoevo/settings/EvolutionSettings.java @@ -54,6 +54,20 @@ public class EvolutionSettings extends Settings { "The chance that a neuron will be deleted when mutating a cell.", 0.1); + // Per-generation mutation budgets. Were hardcoded static fields in + // NetworkGenome — moving them here lets you actually tune evolution + // pressure without recompiling. Lower values give stable lineages and + // slow drift; higher values let the population explore the landscape + // faster but at the cost of losing locked-in good circuits to noise. + public final Parameter nodeMutationsPerGeneration = new Parameter<>( + "Node Mutations Per Generation", + "Number of neuron-gene mutation attempts each new offspring receives.", + 10); + public final Parameter synapseMutationsPerGeneration = new Parameter<>( + "Synapse Mutations Per Generation", + "Number of synapse-gene mutation attempts each new offspring receives.", + 10); + public EvolutionSettings() { super("Evolution"); } diff --git a/core/src/com/protoevo/settings/PlantSettings.java b/core/src/com/protoevo/settings/PlantSettings.java index 51979b6..e7f7bce 100644 --- a/core/src/com/protoevo/settings/PlantSettings.java +++ b/core/src/com/protoevo/settings/PlantSettings.java @@ -32,10 +32,30 @@ public class PlantSettings extends Settings { "Min Health to Split", "Minimum health required to produce children.", 0.15f); + // Probabilistic split rate (per sim-second). The old behaviour was + // "split immediately on reaching maxRadius", which made plant pop + // churn: every plant at adult size burst on the same tick, parents + // died, kids grew, repeat. With a rate of 1/30 a mature plant lingers + // ~30 sec before splitting on average — that lets it act as stable + // food for protozoa and gives the plant PID a smooth dial to throttle + // reproduction proportionally instead of an on/off switch. + public final Parameter splitRate = new Parameter<>( + "Plant Split Rate", + "Probability per sim-second that a mature plant burst-splits.", + 1f / 30f); + public final Parameter maxRotationTorque = new Parameter<>( + "Plant Max Rotation Torque", + "Maximum torque a plant can apply per frame from its Rotate output.", + 1e-5f); + // Plants don't need fast cognition — they sit, photosynthesise, and very + // occasionally express a defensive spike. At the old 0.1s sim-time + // interval, 1500 plants running reflective GRN updates dominated the + // fast-forward (128×) frame budget (~1s of GRN per render frame). Bumped + // to 2s sim time: still plenty for plant adaptation but ~20× cheaper. public final Parameter geneExpressionInterval = new Parameter<>( "Gene Expression Interval", "The amount of in-simulation time between ticking the Gene Regulatory Networks of plants.", - Environment.settings.simulationUpdateDelta.get() * 100f, + Environment.settings.simulationUpdateDelta.get() * 2000f, Statistics.ComplexUnit.TIME ); public final Parameter photosynthesizeEnergyRate = new Parameter<>( @@ -48,10 +68,16 @@ public class PlantSettings extends Settings { "Construction mass generated by plants per unit time.", 10f ); + // Default ON: plants now use the @EvolvableList surface-node mechanism to + // grow spikes and photoreceptors. With this off, Evolvable.createNew is + // skipped and plants are spawned through the bare constructor, which + // leaves surfaceNodes empty — so no plant ever grows a spike/photoreceptor + // even though the @EvolvableList annotation is in place. Flip this off + // again for runs that explicitly want non-evolving plants. public final Parameter evolutionEnabled = new Parameter<>( "Plant Evolution", "Whether or not plants pass genetic information to their offspring.", - false + true ); public PlantSettings() { diff --git a/core/src/com/protoevo/settings/ProtozoaSettings.java b/core/src/com/protoevo/settings/ProtozoaSettings.java index 80ad66e..ec1b2ca 100644 --- a/core/src/com/protoevo/settings/ProtozoaSettings.java +++ b/core/src/com/protoevo/settings/ProtozoaSettings.java @@ -27,6 +27,38 @@ public class ProtozoaSettings extends Settings { "Starvation Factor", "The rate at which a protozoan's health is reduced when it is not eating.", .85f); + // Senescence: age-driven damage that fires REGARDLESS of energy state. + // Without this, a well-fed cell that hit max radius would never die — no + // energy starvation, growth stopped, splits blocked by health < + // minHealthToSplit, just sits there indefinitely. Players reported cells + // alive for 2000+ sim-seconds doing nothing. + // + // Curve: below maxLifespan -> no senescence damage. Past maxLifespan, + // damage rate grows quadratically with how far over the cell is: + // excess = (timeAlive - maxLifespan) / maxLifespan // in lifespans + // rate = excess² × senescenceDeathRate // health/sec + // + // At 1× lifespan: 0 damage. At 2× lifespan: peak rate. At 3× lifespan: + // 4× peak rate (guaranteed death within tens of sim-seconds). So a healthy + // protozoan typically dies somewhere between 1.5× and 2× maxLifespan, + // never significantly past 3×. Tunable per-run via the settings UI. + public final Parameter maxLifespan = new Parameter<>( + "Max Lifespan", + "Sim-seconds of life before age-driven damage starts accumulating.", + // 500 → 1500. With 500, cells reach effective death at ~750-1000 + // sim-sec — but the ratchet interval is now 240s and the + // reproductive cycle takes 60-120s, so a cell at the old cap + // only saw 3-5 reproductive opportunities and 2-4 ratchet + // steps before dying. Not enough generations for selection to + // track each tightening step. 1500s lifespan (effective death + // ~2250-3000s) gives lineages a real shot at evolving through + // each ratchet step before the next one lands. + 1500f); + public final Parameter senescenceDeathRate = new Parameter<>( + "Senescence Death Rate", + "Peak senescence damage in health/sec (reached at 2× max lifespan, " + + "scales quadratically past max lifespan).", + 0.02f); public final Parameter minHealthToSplit = new Parameter<>( "Min Health to Split", "The minimum health required to produce children.", @@ -54,10 +86,19 @@ public class ProtozoaSettings extends Settings { // "", // "", // maxBirthRadius.get() * 1.2f); + // Bumped from 0 → 0.4. The evolvable "Growth Rate" trait gets remapped + // from [0,1] to [minGrowthRate, maxGrowthRate]; with a floor of 0, a + // lineage that evolved its trait toward 0 had effective growth rate ~0 + // and never reached split radius, no matter how well it ate. Worse, + // construction mass that arrived from feeding got immediately consumed + // by the cell's surface nodes (each of which tries to build its + // attachment every tick), so the user saw "mass appears then vanishes + // with no growth." A non-zero floor guarantees growth wins some of the + // mass each tick. public final Parameter minProtozoanGrowthRate = new Parameter<>( "Min Growth Rate", "The minimum growth factor of a protozoan.", - 0f); + 0.4f); public final Parameter maxProtozoanGrowthRate = new Parameter<>( "Max Growth Rate", "The maximum growth factor of a protozoan.", diff --git a/core/src/com/protoevo/settings/SimulationSettings.java b/core/src/com/protoevo/settings/SimulationSettings.java index a8893bc..a537d41 100644 --- a/core/src/com/protoevo/settings/SimulationSettings.java +++ b/core/src/com/protoevo/settings/SimulationSettings.java @@ -21,22 +21,31 @@ public class SimulationSettings extends Settings { public final Parameter finishOnProtozoaExtinction = new Parameter<>( "Finish on Extinction", "Whether to stop the simulation when all Protozoa go extinct.", - true + // Default false now: the homeostat auto-respawns 250 seeded + // protozoa when extinction is detected, so the sim can keep + // running unattended overnight. + false ); // public final Settings.Parameter repopOnProtozoaExtinction = new Settings.Parameter<>( // "Repopulate on Extinction", // "Whether to repopulate the world when all Protozoa go extinct (overrides Finish on Extinction).", // true // ); + // Doubled from 1e5/3e5. The original eat() code had a bug that double-added + // mass to fresh food chunks, so cells got ~2x the energy per meal that the + // numbers implied. Fixing the bug halved real food yield, which was enough + // to crash populations that previously survived. Bumping the densities + // restores roughly the original delivered nutrition without re-introducing + // the duplication bug. public final Parameter plantEnergyDensity = new Parameter<>( "Plant Energy Density", "Energy per unit mass of plant material.", - 1e5f + 2e5f ); public final Parameter meatEnergyDensity = new Parameter<>( "Meat Energy Density", "Energy per unit mass of meat material.", - 3e5f + 6e5f ); public final Parameter meatDeathFactor = new Parameter<>( diff --git a/core/src/com/protoevo/ui/SimulationInputManager.java b/core/src/com/protoevo/ui/SimulationInputManager.java index 1d5af61..b146c58 100644 --- a/core/src/com/protoevo/ui/SimulationInputManager.java +++ b/core/src/com/protoevo/ui/SimulationInputManager.java @@ -70,6 +70,17 @@ public SimulationInputManager(SimulationScreen simulationScreen) { graphics.setScreen(new StatsGraphsScreen(graphics, simulation, simulationScreen)); }); + // Genetic-clusters snapshot. Wired from the main top bar (not from the + // stats screen) to keep the click path short. + topBar.createRightBarImageButton("icons/multicell-icon.png", () -> { + graphics.setScreen(new com.protoevo.ui.screens.GeneticClustersScreen( + graphics, simulation, simulationScreen)); + }); + + // Turbo mode: black-screen line-chart fast-forward (F8 hotkey). + topBar.createRightBarImageButton("icons/fast_forward.png", + simulationScreen::toggleTurboMode); + possibleSpawnables.put("Plant Cell", () -> Evolvable.createNew(PlantCell.class)); possibleSpawnables.put("Random Protozoan", () -> Evolvable.createNew(Protozoan.class)); tryLoadSavedProtozoans(); @@ -92,13 +103,27 @@ public SimulationInputManager(SimulationScreen simulationScreen) { topBar.createLeftBarImageButton("icons/fast_forward.png", simulation::toggleTimeDilation); topBar.createLeftBarImageButton("icons/home_icon.png", simulationScreen::resetCamera); topBar.createLeftBarImageButton("icons/folder.png", simulation::openSaveFolderOnDesktop); - topBar.createLeftBarImageButton("icons/terminal.png", graphics::switchToHeadlessMode); + // Removed: the "terminal" button called switchToHeadlessMode, which + // tore down the OpenGL context. The chemical solution's diffusion + // pass uses a GL compute shader though, so the next sim tick after + // the button click would crash with no GL context. The REPL is + // already running on its own thread reading the launching + // terminal's stdin — the button never gave any new functionality, + // it just destroyed the window. topBar.createLeftBarToggleImageButton( "icons/meander_disabled.png", "icons/meander_enabled.png", simulationScreen::toggleMeandering ); + // Low-detail mode toggle: hides chemicals + shadows for big FPS win. + // Using help_icon.png as a stand-in — the asset set has no "graphics + // detail" icon. Hotkey F10 does the same thing. + topBar.createLeftBarImageButton( + "icons/help_icon.png", + simulationScreen::toggleLowDetailMode + ); + // Input layers lightningButton = new LightningButton(this, topBar.getButtonSize()); diff --git a/core/src/com/protoevo/ui/input/ParticleTracker.java b/core/src/com/protoevo/ui/input/ParticleTracker.java index 933205a..1e19d1c 100644 --- a/core/src/com/protoevo/ui/input/ParticleTracker.java +++ b/core/src/com/protoevo/ui/input/ParticleTracker.java @@ -18,8 +18,15 @@ public class ParticleTracker extends InputAdapter { private final OrthographicCamera camera; private final PanZoomCameraInput panZoomCameraInput; private Particle trackedParticle; + // Secondary particle for BLAST-style signature comparison. Set by + // shift-click while a primary is already tracked; cleared whenever + // the primary is cleared. + private Particle comparedParticle; private boolean canTrack = true; + public Particle getComparedParticle() { return comparedParticle; } + public void setComparedParticle(Particle p) { this.comparedParticle = p; } + public ParticleTracker(SimulationScreen screen, PanZoomCameraInput panZoomCameraInput) { this.simulationScreen = screen; @@ -54,10 +61,25 @@ public boolean track(Vector2 touchPos) { public void untrack() { trackedParticle = null; + comparedParticle = null; panZoomCameraInput.setPanningDisabled(false); simulationScreen.pollStats(); } + /** Shift-click variant: leave the primary tracked particle in place, + * set the secondary so the signature HUD can BLAST them side-by-side. */ + public boolean trackComparison(Vector2 touchPos) { + if (!canTrack || trackedParticle == null) return false; + Optional particle = simulationScreen.getEnvironment().getParticles() + .filter(p -> Geometry.isPointInsideCircle(p.getPos(), p.getRadius(), touchPos)) + .findFirst(); + if (particle.isPresent() && particle.get() != trackedParticle) { + comparedParticle = particle.get(); + return true; + } + return false; + } + private boolean untrack(Vector2 touchPos) { if (!Geometry.isPointInsideCircle(trackedParticle.getPos(), trackedParticle.getRadius(), touchPos)) untrack(); @@ -72,10 +94,18 @@ public boolean touchDown(int screenX, int screenY, int pointer, int button) { if (Gdx.input.isButtonPressed(Input.Buttons.LEFT)) { Vector3 worldSpace = camera.unproject(new Vector3(screenX, screenY, 0)); + Vector2 world = new Vector2(worldSpace.x, worldSpace.y); + // Shift+left-click while already tracking → set comparison + // (for the signature HUD's BLAST-style alignment view). Doesn't + // change the primary tracked particle. + boolean shift = Gdx.input.isKeyPressed(Input.Keys.SHIFT_LEFT) + || Gdx.input.isKeyPressed(Input.Keys.SHIFT_RIGHT); + if (shift && trackedParticle != null) + return trackComparison(world); if (trackedParticle == null) - return track(new Vector2(worldSpace.x, worldSpace.y)); + return track(world); else - return untrack(new Vector2(worldSpace.x, worldSpace.y)); + return untrack(world); } return false; } diff --git a/core/src/com/protoevo/ui/input/SimulationKeyboardControls.java b/core/src/com/protoevo/ui/input/SimulationKeyboardControls.java index e3b8ac1..788c146 100644 --- a/core/src/com/protoevo/ui/input/SimulationKeyboardControls.java +++ b/core/src/com/protoevo/ui/input/SimulationKeyboardControls.java @@ -20,7 +20,41 @@ public SimulationKeyboardControls(SimulationScreen simulationScreen) { keyFunctions.put(Input.Keys.SPACE, simulation::togglePause); keyFunctions.put(Input.Keys.F12, screen::toggleUI); + keyFunctions.put(Input.Keys.F4, screen::toggleLineageOverlay); keyFunctions.put(Input.Keys.ESCAPE, screen::moveToPauseScreen); + + // Speed controls: ] faster, [ slower, \ reset to 1x + keyFunctions.put(Input.Keys.RIGHT_BRACKET, () -> bumpSpeed(true)); + keyFunctions.put(Input.Keys.LEFT_BRACKET, () -> bumpSpeed(false)); + keyFunctions.put(Input.Keys.BACKSLASH, () -> { + simulation.setTimeDilation(1f); + System.out.println("Speed: 1.0x"); + }); + + // Perf toggles: F10 low-detail (both), F11 chemicals only. + keyFunctions.put(Input.Keys.F10, screen::toggleLowDetailMode); + keyFunctions.put(Input.Keys.F11, () -> { + var r = screen.getBaseEnvironmentRenderer(); + r.setRenderChemicals(!r.isRenderChemicals()); + System.out.println("Render chemicals: " + r.isRenderChemicals()); + }); + // Turbo: black screen + line chart + max sim throughput. Good for + // leaving the sim running overnight. + keyFunctions.put(Input.Keys.F8, screen::toggleTurboMode); + + // Homeostat: dynamically adjust food density / decay rate to track a + // target population so evolution always has selection pressure. + keyFunctions.put(Input.Keys.F7, () -> + simulation.setHomeostasisEnabled(!simulation.isHomeostasisEnabled())); + } + + private void bumpSpeed(boolean faster) { + float td = simulation.getTimeDilation(); + // Speed steps: ..., 0.25, 0.5, 1, 2, 4, 8, 16, 32, 64, 128, 256 + if (faster) td = (td < 0.25f) ? 0.25f : Math.min(td * 2f, 256f); + else td = (td > 32f) ? 32f : Math.max(td * 0.5f, 0.0625f); + simulation.setTimeDilation(td); + System.out.printf("Speed: %.3fx%n", td); } @Override diff --git a/core/src/com/protoevo/ui/plotting/PlotGrid.java b/core/src/com/protoevo/ui/plotting/PlotGrid.java index bad0db2..5f858a2 100644 --- a/core/src/com/protoevo/ui/plotting/PlotGrid.java +++ b/core/src/com/protoevo/ui/plotting/PlotGrid.java @@ -113,13 +113,23 @@ public void setPlotBoundsX(float xMin, float xMax) { } public void setMajorTicks(float xMajorTick, float yMajorTick) { - this.xMajorTick = xMajorTick; - this.yMajorTick = yMajorTick; + // Guard against zero/NaN: callers compute these from data range / 5, + // and on fresh sims with only one snapshot the range is 0. Without + // this clamp the draw loops below become `for (x = 0; ...; x += 0)` + // and the render thread spins forever inside GlyphLayout, hanging + // the whole window. + this.xMajorTick = sanitizeTick(xMajorTick); + this.yMajorTick = sanitizeTick(yMajorTick); } public void setMinorTicks(float xMinorTick, float yMinorTick) { - this.xMinorTick = xMinorTick; - this.yMinorTick = yMinorTick; + this.xMinorTick = sanitizeTick(xMinorTick); + this.yMinorTick = sanitizeTick(yMinorTick); + } + + private static float sanitizeTick(float t) { + if (!Float.isFinite(t) || t <= 0f) return 1f; + return t; } @Override @@ -163,22 +173,35 @@ private void drawAxisYTickLabel(Batch batch, float y) { public void drawAxis(Batch batch) { + // Hard cap on iterations as a second line of defense; if a tick step + // is somehow tiny relative to the bounds we'd otherwise burn the + // render thread allocating GlyphLayouts forever. + final int MAX_TICKS = 200; + // draw grid - for (float x = 0; x <= xMax; x += xMajorTick) { - drawMajorXGridLine(x); - drawAxisXTickLabel(batch, x); - } - for (float x = 0; x >= xMin; x -= xMajorTick) { - drawMajorXGridLine(x); - drawAxisXTickLabel(batch, x); - } - for (float y = 0; y <= yMax; y += yMajorTick) { - drawMajorYGridLine(y); - drawAxisYTickLabel(batch, y); + if (xMajorTick > 0f) { + int n = 0; + for (float x = 0; x <= xMax && n < MAX_TICKS; x += xMajorTick, n++) { + drawMajorXGridLine(x); + drawAxisXTickLabel(batch, x); + } + n = 0; + for (float x = 0; x >= xMin && n < MAX_TICKS; x -= xMajorTick, n++) { + drawMajorXGridLine(x); + drawAxisXTickLabel(batch, x); + } } - for (float y = 0; y >= yMin; y -= yMajorTick) { - drawMajorYGridLine(y); - drawAxisYTickLabel(batch, y); + if (yMajorTick > 0f) { + int n = 0; + for (float y = 0; y <= yMax && n < MAX_TICKS; y += yMajorTick, n++) { + drawMajorYGridLine(y); + drawAxisYTickLabel(batch, y); + } + n = 0; + for (float y = 0; y >= yMin && n < MAX_TICKS; y -= yMajorTick, n++) { + drawMajorYGridLine(y); + drawAxisYTickLabel(batch, y); + } } // // draw ticks on axis diff --git a/core/src/com/protoevo/ui/rendering/ChemicalsRenderer.java b/core/src/com/protoevo/ui/rendering/ChemicalsRenderer.java index 559fa1e..937876a 100644 --- a/core/src/com/protoevo/ui/rendering/ChemicalsRenderer.java +++ b/core/src/com/protoevo/ui/rendering/ChemicalsRenderer.java @@ -8,8 +8,11 @@ import com.badlogic.gdx.graphics.glutils.ShaderProgram; import com.protoevo.env.ChemicalSolution; import com.protoevo.env.Environment; +import com.protoevo.utils.Colour; import com.protoevo.utils.DebugMode; +import java.nio.ByteBuffer; + public class ChemicalsRenderer implements Renderer { private Environment environment; private final ChemicalSolution chemicalSolution; @@ -17,6 +20,8 @@ public class ChemicalsRenderer implements Renderer { private final ShaderProgram shader; private final Texture chemicalTexture; private final Pixmap chemicalPixmap; + private final ByteBuffer pixmapBuffer; + private final int chemWidth, chemHeight; private final OrthographicCamera camera; public ChemicalsRenderer(OrthographicCamera camera, Environment environment) { @@ -24,17 +29,22 @@ public ChemicalsRenderer(OrthographicCamera camera, Environment environment) { this.camera = camera; chemicalSolution = environment.getChemicalSolution(); - int w = chemicalSolution.getNXCells(); - int h = chemicalSolution.getNYCells(); + chemWidth = chemicalSolution.getNXCells(); + chemHeight = chemicalSolution.getNYCells(); - chemicalPixmap = new Pixmap(w, h, Pixmap.Format.RGBA8888); + chemicalPixmap = new Pixmap(chemWidth, chemHeight, Pixmap.Format.RGBA8888); chemicalPixmap.setBlending(Pixmap.Blending.None); + pixmapBuffer = chemicalPixmap.getPixels(); chemicalTexture = new Texture(chemicalPixmap); - chemicalSolution.setUpdateChemicalCallback((x, y, c) -> { - chemicalPixmap.drawPixel(x, y, c.getRGBA8888()); - }); + // No more per-pixel callback. The old design fired a JNI + // Pixmap.drawPixel call on every chemical-cell set() — at 1024×1024 + // resolution with a diffuse touching every cell, that was ~1M JNI + // hops per chem update, all on the sim thread but blocking the + // shared pixmap object the render thread reads. Now sim just flips + // a dirty bit and render bulk-copies the float array into the + // pixmap's underlying ByteBuffer in one sweep. batch = new SpriteBatch(); shader = new ShaderProgram( @@ -45,13 +55,49 @@ public ChemicalsRenderer(OrthographicCamera camera, Environment environment) { } } + private void refreshPixmap() { + Colour[][] colours = chemicalSolution.getImage(); + ByteBuffer buf = pixmapBuffer; + buf.position(0); + for (int y = 0; y < chemHeight; y++) { + for (int x = 0; x < chemWidth; x++) { + Colour c = colours[x][y]; + int r = clamp255((int) (c.r * 255)); + int g = clamp255((int) (c.g * 255)); + int b = clamp255((int) (c.b * 255)); + int a = clamp255((int) (c.a * 255)); + buf.put((byte) r); + buf.put((byte) g); + buf.put((byte) b); + buf.put((byte) a); + } + } + buf.position(0); + } + + private static int clamp255(int v) { + if (v < 0) return 0; + if (v > 255) return 255; + return v; + } + public void render(float delta) { ChemicalSolution chemicalSolution = environment.getChemicalSolution(); if (chemicalSolution == null || chemicalTexture == null) return; - chemicalTexture.draw(chemicalPixmap, 0, 0); + // Refresh pixmap from the chemical array only when sim flagged a + // change. Most frames the chemicals didn't actually update this + // tick (sim batches them to once per render at most), so we skip + // the whole bulk-copy and texture upload. clearDirty AFTER read so + // a concurrent set() during the copy still leaves us dirty for the + // next frame. + if (chemicalSolution.isDirty()) { + refreshPixmap(); + chemicalSolution.clearDirty(); + chemicalTexture.draw(chemicalPixmap, 0, 0); + } batch.enableBlending(); batch.setProjectionMatrix(camera.combined); diff --git a/core/src/com/protoevo/ui/rendering/EnvironmentRenderer.java b/core/src/com/protoevo/ui/rendering/EnvironmentRenderer.java index 1b9076e..b687492 100644 --- a/core/src/com/protoevo/ui/rendering/EnvironmentRenderer.java +++ b/core/src/com/protoevo/ui/rendering/EnvironmentRenderer.java @@ -15,6 +15,7 @@ import com.badlogic.gdx.utils.ScreenUtils; import com.protoevo.biology.cells.Cell; import com.protoevo.biology.cells.Protozoan; +import com.protoevo.biology.nodes.SurfaceNode; import com.protoevo.env.Environment; import com.protoevo.maths.Functions; import com.protoevo.physics.*; @@ -29,6 +30,8 @@ import java.util.Collection; import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.Optional; public class EnvironmentRenderer implements Renderer { @@ -37,6 +40,11 @@ public class EnvironmentRenderer implements Renderer { private final SpriteBatch batch; private final Texture particleTexture; private final HashMap protozoaRenderers = new HashMap<>(); + // Per-cell node renderer caches for non-Protozoa cells (currently used by + // plants now that they grow surface nodes). Keyed by Cell, not Particle, + // because that's what NodeRenderer.isStale() checks for death. + private final HashMap> nonProtozoaNodeRenderers = + new HashMap<>(); private final Sprite jointSprite; private final ShapeRenderer debugRenderer; private final ShapeDrawer shapeDrawer; @@ -47,6 +55,17 @@ public class EnvironmentRenderer implements Renderer { private final LightRenderer lightRenderer; private final Vector2 tmpVec = new Vector2(); private final Color tmpColor = new Color(); + // Runtime perf toggles. Default OFF (low-detail mode) so a fresh launch + // is at high FPS out of the box — sustained sim runs care more about + // throughput than visual fidelity. F10 toggles both back on if you + // want the prettier render. F11 toggles chemicals only. + private boolean renderChemicals = false; + private boolean renderShadows = false; + + public boolean isRenderChemicals() { return renderChemicals; } + public void setRenderChemicals(boolean v) { renderChemicals = v; } + public boolean isRenderShadows() { return renderShadows; } + public void setRenderShadows(boolean v) { renderShadows = v; } public static Sprite loadSprite(String path) { Texture texture = new Texture(Gdx.files.internal(path), true); @@ -121,23 +140,34 @@ && circleNotVisible(p2.getPos(), p2.getRadius())) { public void render(float delta) { ScreenUtils.clear(backgroundColor); - if (chemicalsRenderer != null) + if (chemicalsRenderer != null && renderChemicals) chemicalsRenderer.render(delta); batch.enableBlending(); batch.setProjectionMatrix(camera.combined); // Render Particles batch.begin(); - if (camera.zoom < 3) - environment.getJointsManager().getJoinings() - .forEach(this::renderJoinedParticles); - environment.getParticles() - .filter(p -> !circleNotVisible(p.getPos(), p.getRadius())) - .iterator() - .forEachRemaining(p -> drawParticle(delta, p)); + if (camera.zoom < 3) { + // Old: getJoinings().forEach(this::renderJoinedParticles) — the + // method reference + stream iterator allocated per frame. Use a + // plain for loop to keep this hot path allocation-free. + for (Joining joining : environment.getJointsManager().getJoinings()) + renderJoinedParticles(joining); + } + // Old: getParticles() built a Stream → filter Stream → iterator → + // forEachRemaining(lambda). Three allocations per render frame. + // Iterate cells directly and pull the particle inline — same logic, + // zero per-frame garbage on the visibility-cull pass. + for (Cell cell : environment.getCells()) { + Particle p = cell.getParticle(); + if (circleNotVisible(p.getPos(), p.getRadius())) + continue; + drawParticle(delta, p); + } batch.end(); - lightRenderer.render(delta); + if (renderShadows) + lightRenderer.render(delta); batch.begin(); renderRocks(); @@ -145,6 +175,8 @@ public void render(float delta) { protozoaRenderers.entrySet() .removeIf(entry -> entry.getValue().isStale()); + nonProtozoaNodeRenderers.entrySet() + .removeIf(entry -> entry.getKey().isDead()); if (DebugMode.isInteractionInfo()) @@ -296,12 +328,27 @@ public void drawParticle(float delta, Particle p) { } } else { + // Plain circle base layer for non-Protozoa cells. float x = p.getPos().x - p.getRadius(); float y = p.getPos().y - p.getRadius(); float r = p.getRadius() * 2; - Color c = p.getUserData(Cell.class).getColor(); + Cell cell = p.getUserData(Cell.class); + Color c = cell.getColor(); batch.setColor(c.r, c.g, c.b, 1.0f); batch.draw(particleTexture, x, y, r, r); + + // Plants now grow surface nodes (spikes / photoreceptors); render + // them with the same cached-renderer pattern Protozoa use. + List surfaceNodes = cell.getSurfaceNodes(); + if (surfaceNodes != null && !surfaceNodes.isEmpty()) { + Map renderers = + nonProtozoaNodeRenderers.computeIfAbsent(cell, k -> new HashMap<>()); + renderers.entrySet().removeIf(e -> e.getValue().isStale()); + for (SurfaceNode node : surfaceNodes) { + renderers.computeIfAbsent(node, NodeRenderer::createFor) + .render(delta, batch); + } + } } } diff --git a/core/src/com/protoevo/ui/rendering/NodeRenderer.java b/core/src/com/protoevo/ui/rendering/NodeRenderer.java index 61a1eb7..25f263a 100644 --- a/core/src/com/protoevo/ui/rendering/NodeRenderer.java +++ b/core/src/com/protoevo/ui/rendering/NodeRenderer.java @@ -10,10 +10,31 @@ import java.util.HashMap; import java.util.Map; import java.util.Optional; +import java.util.function.Function; public class NodeRenderer { private static Sprite nodeEmptySprite = null; + // Factory map shared by every cell type — picks the right NodeRenderer + // subclass for the attachment a node is currently expressing. Lives here + // (rather than ProtozoaRenderer) because plants now grow nodes too. + private static final Map, + Function> rendererFactory = + new HashMap, + Function>() {{ + put(Flagellum.class, FlagellumRenderer::new); + put(Photoreceptor.class, PhotoreceptorRenderer::new); + put(Spike.class, SpikeRenderer::new); + put(AdhesionReceptor.class, AdhesionRenderer::new); + }}; + + public static NodeRenderer createFor(SurfaceNode node) { + NodeAttachment attachment = node.getAttachment(); + if (attachment == null || !rendererFactory.containsKey(attachment.getClass())) + return new NodeRenderer(node); + return rendererFactory.get(attachment.getClass()).apply(node); + } + protected static Sprite getNodeEmptySprite() { if (nodeEmptySprite == null) nodeEmptySprite = ImageUtils.loadSprite("cell/nodes/node_empty.png"); diff --git a/core/src/com/protoevo/ui/rendering/ProtozoaRenderer.java b/core/src/com/protoevo/ui/rendering/ProtozoaRenderer.java index b39e4a5..f14c95f 100644 --- a/core/src/com/protoevo/ui/rendering/ProtozoaRenderer.java +++ b/core/src/com/protoevo/ui/rendering/ProtozoaRenderer.java @@ -22,7 +22,6 @@ import java.util.HashMap; import java.util.Map; import java.util.Optional; -import java.util.function.Function; public class ProtozoaRenderer { @@ -96,17 +95,6 @@ public static void dispose() { } } - private static final - Map, Function> nodeRendererMap = - new HashMap, Function>(){ - { - put(Flagellum.class, FlagellumRenderer::new); - put(Photoreceptor.class, PhotoreceptorRenderer::new); - put(Spike.class, SpikeRenderer::new); - put(AdhesionReceptor.class, AdhesionRenderer::new); - } - }; - private static class InteriorElement { private final Sprite elementSprite; private float angle; @@ -146,6 +134,11 @@ public void draw(float delta, OrthographicCamera camera, SpriteBatch batch) { private final Protozoan protozoan; private final Map nodeRenderers; private final ArrayList interiorElements = new ArrayList<>(0); + // Reused per-cell scratch Color so getProtozoanColor doesn't allocate a + // new Color object each render frame. With 250 protozoa × 60fps this + // was ~15k allocations/sec just for damage flashes — visible as GC + // micro-stutter on long runs. + private final Color reusableColor = new Color(); public ProtozoaRenderer(Protozoan protozoan) { this.protozoan = protozoan; @@ -160,11 +153,7 @@ public ProtozoaRenderer(Protozoan protozoan) { } public NodeRenderer createNodeRenderer(SurfaceNode node) { - NodeAttachment maybeAttachment = node.getAttachment(); - if (maybeAttachment == null || !nodeRendererMap.containsKey(maybeAttachment.getClass())) { - return new NodeRenderer(node); - } - return nodeRendererMap.get(maybeAttachment.getClass()).apply(node); + return NodeRenderer.createFor(node); } public Color getProtozoanColor() { @@ -190,7 +179,7 @@ public Color getProtozoanColor() { float maxRedAmount = GraphicsAdapter.settings.damageVisualRedAmount.get(); - return new Color(protozoan.getColor()) + return reusableColor.set(protozoan.getColor()) .lerp(Color.RED, damageAmountFactor * damageTimeFactor * maxRedAmount); } return protozoan.getColor(); diff --git a/core/src/com/protoevo/ui/screens/GeneticClustersScreen.java b/core/src/com/protoevo/ui/screens/GeneticClustersScreen.java new file mode 100644 index 0000000..699a80e --- /dev/null +++ b/core/src/com/protoevo/ui/screens/GeneticClustersScreen.java @@ -0,0 +1,254 @@ +package com.protoevo.ui.screens; + +import com.badlogic.gdx.Gdx; +import com.badlogic.gdx.ScreenAdapter; +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.graphics.Pixmap; +import com.badlogic.gdx.graphics.Texture; +import com.badlogic.gdx.scenes.scene2d.Stage; +import com.badlogic.gdx.scenes.scene2d.ui.Image; +import com.badlogic.gdx.scenes.scene2d.ui.Label; +import com.badlogic.gdx.scenes.scene2d.ui.Table; +import com.badlogic.gdx.scenes.scene2d.utils.TextureRegionDrawable; +import com.badlogic.gdx.utils.Align; +import com.badlogic.gdx.utils.ScreenUtils; +import com.protoevo.biology.cells.Cell; +import com.protoevo.biology.cells.Protozoan; +import com.protoevo.biology.nn.NetworkGenome; +import com.protoevo.core.Simulation; +import com.protoevo.ui.GraphicsAdapter; +import com.protoevo.ui.TopBar; +import com.protoevo.utils.CursorUtils; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Snapshot of living protozoa grouped by NEAT compatibility distance. + * + * Intentionally minimal: no FBO, no own SpriteBatch, no own ShaderProgram, no + * worker thread. The previous fancier version triggered a hang in the libGDX + * stack on click; stripping it down avoids touching that path. + */ +public class GeneticClustersScreen extends ScreenAdapter { + + private static final float SPECIATION_THRESHOLD = 1.5f; + private static final int MAX_CLUSTERS_DISPLAYED = 30; + private static final int MAX_PROTOZOA_TO_CLUSTER = 250; + private static final int MAX_CLUSTERS_TRACKED = 40; + + private final Simulation simulation; + private final GraphicsAdapter graphics; + private final Stage stage; + + private boolean wasSimulationPaused; + private final List swatchTextures = new ArrayList<>(); + + public GeneticClustersScreen(GraphicsAdapter graphics, Simulation simulation, + SimulationScreen simulationScreen) { + this.graphics = graphics; + this.simulation = simulation; + this.stage = new Stage(); + + TopBar topBar = new TopBar(stage, + graphics.getSkin().getFont("default").getLineHeight()); + topBar.createRightBarImageButton("icons/back.png", graphics::moveToPreviousScreen); + } + + private static final class Cluster { + final NetworkGenome representative; + int count = 0; + float energySum = 0f, healthSum = 0f, ageSum = 0f, genSum = 0f; + float colorRSum = 0f, colorGSum = 0f, colorBSum = 0f; + + Cluster(NetworkGenome rep) { this.representative = rep; } + + void add(Protozoan p) { + count++; + energySum += safe(p.getEnergyAvailable()); + healthSum += safe(p.getHealth()); + ageSum += safe(p.getTimeAlive()); + genSum += p.getGeneration(); + try { + var c = p.getColour(); + colorRSum += c.r; colorGSum += c.g; colorBSum += c.b; + } catch (Throwable ignored) {} + } + + private static float safe(float v) { return Float.isFinite(v) ? v : 0f; } + } + + private List buildClusters() { + List built = new ArrayList<>(); + if (simulation.getEnv() == null) return built; + + List kept = new ArrayList<>(); + List genomes = new ArrayList<>(); + try { + for (Cell cell : new ArrayList<>(simulation.getEnv().getCells())) { + if (!(cell instanceof Protozoan)) continue; + Protozoan p = (Protozoan) cell; + NetworkGenome g; + try { g = p.getGeneExpressionFunction().getGRNGenome(); } + catch (Throwable t) { continue; } + if (g == null) continue; + kept.add(p); + genomes.add(g); + } + } catch (Throwable t) { + t.printStackTrace(); + return built; + } + + if (kept.size() > MAX_PROTOZOA_TO_CLUSTER) { + ArrayList idx = new ArrayList<>(kept.size()); + for (int i = 0; i < kept.size(); i++) idx.add(i); + Collections.shuffle(idx); + List kp = new ArrayList<>(MAX_PROTOZOA_TO_CLUSTER); + List kg = new ArrayList<>(MAX_PROTOZOA_TO_CLUSTER); + for (int i = 0; i < MAX_PROTOZOA_TO_CLUSTER; i++) { + int k = idx.get(i); + kp.add(kept.get(k)); kg.add(genomes.get(k)); + } + kept = kp; genomes = kg; + } + + long t0 = System.currentTimeMillis(); + for (int i = 0; i < kept.size(); i++) { + NetworkGenome g = genomes.get(i); + Protozoan p = kept.get(i); + Cluster best = null; + float bestDist = Float.POSITIVE_INFINITY; + for (Cluster c : built) { + float d; + try { d = g.distance(c.representative); } + catch (Throwable t) { continue; } + if (Float.isNaN(d)) continue; + if (d < bestDist) { bestDist = d; best = c; } + } + if (best != null && bestDist < SPECIATION_THRESHOLD) { + best.add(p); + } else if (built.size() < MAX_CLUSTERS_TRACKED) { + Cluster fresh = new Cluster(g); + fresh.add(p); + built.add(fresh); + } else if (best != null) { + best.add(p); + } + } + built.sort((a, b) -> Integer.compare(b.count, a.count)); + System.out.printf("Genetic clusters: %d cells -> %d clusters in %d ms%n", + kept.size(), built.size(), System.currentTimeMillis() - t0); + return built; + } + + @Override + public void show() { + CursorUtils.setDefaultCursor(); + Gdx.input.setInputProcessor(stage); + wasSimulationPaused = Simulation.isPaused(); + simulation.setPaused(true); + renderResults(buildClusters()); + } + + private void renderResults(List clusters) { + disposeSwatches(); + Table content = new Table(); + content.setFillParent(true); + content.top(); + stage.addActor(content); + + // Plain default style label — avoiding "mediumTitle" in case skin + // lookups were on the path that previously hung. + Label title = new Label("Genetic Clusters (" + clusters.size() + ")", + graphics.getSkin()); + title.setAlignment(Align.center); + content.add(title).colspan(7) + .padTop(Gdx.graphics.getHeight() / 20f) + .padBottom(Gdx.graphics.getHeight() / 40f).row(); + + Table grid = new Table(); + addHeader(grid, "Color"); + addHeader(grid, "#"); + addHeader(grid, "Pop"); + addHeader(grid, "Avg Gen"); + addHeader(grid, "Avg Energy"); + addHeader(grid, "Avg Health"); + addHeader(grid, "Avg Age"); + grid.row(); + + if (clusters.isEmpty()) { + grid.add(new Label("No living protozoa to cluster.", graphics.getSkin())) + .colspan(7).pad(20f).row(); + } else { + int shown = Math.min(clusters.size(), MAX_CLUSTERS_DISPLAYED); + for (int i = 0; i < shown; i++) addRow(grid, i + 1, clusters.get(i)); + if (clusters.size() > shown) { + grid.add(new Label( + "... " + (clusters.size() - shown) + " more not shown", + graphics.getSkin())) + .colspan(7).pad(10f).row(); + } + } + content.add(grid).colspan(7).row(); + } + + private void addHeader(Table grid, String text) { + Label l = new Label(text, graphics.getSkin()); + l.setAlignment(Align.center); + grid.add(l).pad(8f).minWidth(Gdx.graphics.getWidth() / 14f); + } + + private void addCell(Table grid, String text) { + Label l = new Label(text, graphics.getSkin()); + l.setAlignment(Align.center); + grid.add(l).pad(6f); + } + + private void addRow(Table grid, int idx, Cluster c) { + float n = Math.max(c.count, 1); + Color avg = new Color(c.colorRSum / n, c.colorGSum / n, c.colorBSum / n, 1f); + Pixmap pm = new Pixmap(24, 24, Pixmap.Format.RGBA8888); + pm.setColor(avg); pm.fill(); + Texture tex = new Texture(pm); pm.dispose(); + swatchTextures.add(tex); + grid.add(new Image(new TextureRegionDrawable(tex))).pad(6f).size(24f, 24f); + + addCell(grid, "#" + idx); + addCell(grid, Integer.toString(c.count)); + addCell(grid, String.format("%.1f", c.genSum / n)); + addCell(grid, String.format("%.2f", c.energySum / n)); + addCell(grid, String.format("%.2f", c.healthSum / n)); + addCell(grid, String.format("%.0fs", c.ageSum / n)); + grid.row(); + } + + @Override + public void render(float delta) { + ScreenUtils.clear(0.05f, 0.06f, 0.10f, 1f); + stage.act(delta); + stage.draw(); + } + + @Override + public void hide() { + Gdx.input.setInputProcessor(null); + simulation.setPaused(wasSimulationPaused); + } + + private void disposeSwatches() { + for (Texture t : swatchTextures) { + try { t.dispose(); } catch (Throwable ignored) {} + } + swatchTextures.clear(); + } + + @Override + public void dispose() { + super.dispose(); + hide(); + disposeSwatches(); + stage.dispose(); + } +} diff --git a/core/src/com/protoevo/ui/screens/SimulationScreen.java b/core/src/com/protoevo/ui/screens/SimulationScreen.java index 64a6890..6eaa727 100644 --- a/core/src/com/protoevo/ui/screens/SimulationScreen.java +++ b/core/src/com/protoevo/ui/screens/SimulationScreen.java @@ -59,7 +59,7 @@ public class SimulationScreen extends ScreenAdapter { private Environment environment; private final SimulationInputManager inputManager; private final ShaderLayers environmentRenderer; - private final VignetteLayer vignetteLayer; + private VignetteLayer vignetteLayer; private final SpriteBatch uiBatch; private final Stage stage; private final GlyphLayout layout = new GlyphLayout(); @@ -142,11 +142,11 @@ public SimulationScreen(GraphicsAdapter graphics, Simulation simulation) { inputManager = new SimulationInputManager(this); brightnessLayer = new BrightnessLayer(camera); - vignetteLayer = new VignetteLayer(camera, inputManager.getParticleTracker()); +// vignetteLayer = new VignetteLayer(camera, inputManager.getParticleTracker()); environmentRenderer = new ShaderLayers( new EnvironmentRenderer(camera, simulation.getEnv(), inputManager), new ShockWaveLayer(camera), - vignetteLayer, + // vignetteLayer, brightnessLayer ); @@ -237,6 +237,454 @@ public void updateEnvironment() { setEnvStatOptions(); } + public EnvironmentRenderer getBaseEnvironmentRenderer() { + return (EnvironmentRenderer) environmentRenderer.getBaseRenderer(); + } + + // ===== Lineage tree overlay ===== + // Phylogenetic tree restricted to *currently-living* protozoa and their + // branch-point ancestors. Time on X (oldest branch point left, now + // right); each visible lineage is a coloured horizontal segment. Linear + // chains (parent → only-child → only-child …) are collapsed: the tree + // only shows actual divergence events plus living leaves, so the + // visible row count is bounded by living population × branchiness + // rather than total cells ever born. + private boolean lineageOverlay = false; + private com.badlogic.gdx.graphics.glutils.ShapeRenderer lineageShape; + private static final int LINEAGE_MAX_VISIBLE_LEAVES = 80; + // Per-render scratch state. + private final HashSet lineageOnLivingPath = new HashSet<>(); + private final HashMap> lineageLivingChildren = + new HashMap<>(); + private final ArrayList lineageVisibleRoots = new ArrayList<>(); + + public void toggleLineageOverlay() { + lineageOverlay = !lineageOverlay; + if (lineageOverlay && lineageShape == null) + lineageShape = new com.badlogic.gdx.graphics.glutils.ShapeRenderer(); + System.out.println("Lineage tree: " + (lineageOverlay ? "ON" : "OFF")); + } + public boolean isLineageOverlay() { return lineageOverlay; } + + private static void lineageColor(long id, com.badlogic.gdx.graphics.Color out) { + // Hashed → saturated colour. Stable per lineage so the eye can + // follow a branch across the tree. + float r = ((id * 2654435761L) >>> 0 & 0xff) / 255f; + float g = ((id * 40503L) >>> 8 & 0xff) / 255f; + float b = ((id * 2246822519L) >>> 16 & 0xff) / 255f; + float maxC = Math.max(r, Math.max(g, b)); + if (maxC > 1e-3f) { r /= maxC; g /= maxC; b /= maxC; } + out.set(r, g, b, 0.95f); + } + + private void rebuildLineageChildren(java.util.Map recs) { + lineageOnLivingPath.clear(); + lineageLivingChildren.clear(); + lineageVisibleRoots.clear(); + // Walk from each currently-alive record up through its ancestors, + // marking every record on a path to a living leaf. We render only + // these. Dead branches (subtrees with no living leaves) are + // automatically excluded. + for (com.protoevo.env.LineageRecord r : recs.values()) { + if (!r.isAlive()) continue; + long id = r.id; + while (id != 0L) { + if (!lineageOnLivingPath.add(id)) break; // already marked + com.protoevo.env.LineageRecord step = recs.get(id); + if (step == null) break; + id = step.parentId; + } + } + // Build the parent → living-children map and find roots. + for (Long id : lineageOnLivingPath) { + com.protoevo.env.LineageRecord r = recs.get(id); + if (r == null) continue; + if (r.parentId == 0L || !lineageOnLivingPath.contains(r.parentId)) { + lineageVisibleRoots.add(r); + } else { + lineageLivingChildren.computeIfAbsent(r.parentId, k -> new ArrayList<>()).add(r); + } + } + for (ArrayList kids : lineageLivingChildren.values()) + kids.sort((a, b) -> Integer.compare(b.aliveDescendants, a.aliveDescendants)); + lineageVisibleRoots.sort((a, b) -> Integer.compare(b.aliveDescendants, a.aliveDescendants)); + } + + /** Walk down single-child chains starting at r, returning the first + * descendant that either branches (≥2 living children) or is a + * living leaf. Used to collapse linear chains so the tree shows only + * divergence events. */ + private com.protoevo.env.LineageRecord collapseChain(com.protoevo.env.LineageRecord r) { + while (true) { + ArrayList kids = lineageLivingChildren.get(r.id); + if (kids == null || kids.size() != 1) return r; + // Exactly one living child — chain continues. If r is dead and + // its single child is also internal, skip through. + r = kids.get(0); + } + } + + private final com.badlogic.gdx.graphics.Color lineageColScratch = new com.badlogic.gdx.graphics.Color(); + + /** + * Draw `r` as a horizontal segment starting at chainStartTime (the + * birth time of the linear-chain's *first* ancestor that was the + * collapse-anchor), and at its branch point recurse into kids. + */ + private void drawLineageSubtree(com.protoevo.env.LineageRecord r, + float chainStartTime, + float xLeft, float xRight, + float yTop, float yBottom, + float tStart, float tEnd) { + float tSpan = Math.max(1e-3f, tEnd - tStart); + float xStart = xLeft + ((chainStartTime - tStart) / tSpan) * (xRight - xLeft); + // For a living leaf, draw out to "now" (xRight). For a dead branch + // point, draw to its own deathTime — that's when its descendants + // diverged from a single common stem. + float xEnd = (r.isAlive() ? xRight + : xLeft + ((Math.max(r.deathTime, r.birthTime) - tStart) / tSpan) + * (xRight - xLeft)); + if (xStart < xLeft) xStart = xLeft; + if (xEnd > xRight) xEnd = xRight; + if (xEnd < xStart) xEnd = xStart; + float yMid = 0.5f * (yTop + yBottom); + + lineageColor(r.id, lineageColScratch); + lineageShape.setColor(lineageColScratch); + // Thickness scales with descendant count (log) so dominant + // branches visually stand out. + float thickness = (float) Math.max(1.5, 1.5 + Math.log(1 + r.aliveDescendants)); + lineageShape.rectLine(xStart, yMid, xEnd, yMid, thickness); + + ArrayList kids = lineageLivingChildren.get(r.id); + if (kids == null || kids.isEmpty()) return; + + int totalKidDescendants = 0; + for (com.protoevo.env.LineageRecord k : kids) totalKidDescendants += k.aliveDescendants; + if (totalKidDescendants <= 0) return; + + float yRange = yTop - yBottom; + float yCursor = yTop; + for (com.protoevo.env.LineageRecord k : kids) { + // Collapse chains: walk down single-child paths from k to its + // first divergence point or living leaf. The horizontal segment + // we draw for the chain runs from k.birthTime out to the + // resolved record's lifespan. + com.protoevo.env.LineageRecord collapsed = collapseChain(k); + float frac = collapsed.aliveDescendants / (float) totalKidDescendants; + float kSize = frac * yRange; + float kYTop = yCursor; + float kYBottom = yCursor - kSize; + float kYMid = 0.5f * (kYTop + kYBottom); + float kBirthX = xLeft + ((k.birthTime - tStart) / tSpan) * (xRight - xLeft); + if (kBirthX < xStart) kBirthX = xStart; + if (kBirthX > xRight) kBirthX = xRight; + // Vertical branch line at the divergence point. + lineageColor(collapsed.id, lineageColScratch); + lineageShape.setColor(lineageColScratch); + lineageShape.rectLine(kBirthX, yMid, kBirthX, kYMid, 1.5f); + drawLineageSubtree(collapsed, k.birthTime, + xLeft, xRight, kYTop, kYBottom, tStart, tEnd); + yCursor = kYBottom; + } + } + + private int countLivingLeaves(com.protoevo.env.LineageRecord r) { + ArrayList kids = lineageLivingChildren.get(r.id); + if (kids == null || kids.isEmpty()) + return r.isAlive() ? 1 : 0; + int n = 0; + for (com.protoevo.env.LineageRecord k : kids) + n += countLivingLeaves(collapseChain(k)); + return n; + } + + private void renderLineageOverlay() { + if (environment == null) return; + java.util.Map recs = + environment.getLineageRecords(); + if (recs.isEmpty()) return; + + rebuildLineageChildren(recs); + if (lineageVisibleRoots.isEmpty()) return; + + // Count living leaves per root (post-chain-collapse) and pick the + // strongest roots that together fit in our visible-leaf budget. + // This keeps each row tall enough to actually see — at 80 leaves + // and ~500px tree height we still get ~6px per row. + int aliveCount = 0; + int rootsShown = 0; + ArrayList roots = lineageVisibleRoots; + for (int i = 0; i < roots.size(); i++) { + com.protoevo.env.LineageRecord root = collapseChain(roots.get(i)); + int leaves = countLivingLeaves(root); + if (leaves <= 0) continue; + if (aliveCount + leaves > LINEAGE_MAX_VISIBLE_LEAVES && rootsShown > 0) + break; + aliveCount += leaves; + rootsShown++; + } + if (rootsShown == 0) return; + + // Time window — from oldest visible root's birth to now. + float now = environment.getElapsedTime(); + float tStart = now; + int totalLeaves = 0; + for (int i = 0; i < rootsShown; i++) { + com.protoevo.env.LineageRecord root = collapseChain(roots.get(i)); + if (root.birthTime < tStart) tStart = root.birthTime; + totalLeaves += countLivingLeaves(root); + } + if (totalLeaves <= 0) return; + float tEnd = now + Math.max(1f, (now - tStart) * 0.02f); + + float w = Gdx.graphics.getWidth(); + float h = Gdx.graphics.getHeight(); + float panelW = w * 0.55f; + float panelH = h * 0.7f; + float panelX = w - panelW - 24f; + float panelY = h * 0.05f; + + Gdx.gl.glEnable(com.badlogic.gdx.graphics.GL20.GL_BLEND); + lineageShape.setProjectionMatrix(uiBatch.getProjectionMatrix()); + lineageShape.begin(com.badlogic.gdx.graphics.glutils.ShapeRenderer.ShapeType.Filled); + lineageShape.setColor(0f, 0f, 0f, 0.7f); + lineageShape.rect(panelX, panelY, panelW, panelH); + + float padX = 14f, padY = 36f; + float treeLeft = panelX + padX; + float treeRight = panelX + panelW - padX; + float treeTop = panelY + panelH - padY; + float treeBottom = panelY + padY; + float yRange = treeTop - treeBottom; + float yCursor = treeTop; + for (int i = 0; i < rootsShown; i++) { + com.protoevo.env.LineageRecord root = collapseChain(roots.get(i)); + int leaves = countLivingLeaves(root); + float frac = leaves / (float) totalLeaves; + float rSize = frac * yRange; + float rYTop = yCursor; + float rYBottom = yCursor - rSize; + drawLineageSubtree(root, root.birthTime, + treeLeft, treeRight, rYTop, rYBottom, tStart, tEnd); + yCursor = rYBottom; + } + lineageShape.end(); + Gdx.gl.glDisable(com.badlogic.gdx.graphics.GL20.GL_BLEND); + + uiBatch.begin(); + font.setColor(1f, 1f, 1f, 1f); + font.draw(uiBatch, + String.format("Phylogeny (currently alive) %d/%d roots, %d leaves", + rootsShown, lineageVisibleRoots.size(), totalLeaves), + panelX + 10f, panelY + panelH - 10f); + font.setColor(0.6f, 0.6f, 0.6f, 1f); + font.draw(uiBatch, String.format("t=%.0fs", tStart), + panelX + 10f, panelY + 22f); + font.draw(uiBatch, String.format("now: t=%.0fs", now), + panelX + panelW - 140f, panelY + 22f); + uiBatch.end(); + } + // ===== end Lineage tree overlay ===== + + // ===== Turbo mode ===== + // Black-screen fast-forward: skip all environment rendering, run sim updates + // in a tight loop within each render frame, and draw a small line chart of + // population over time. Useful for letting the sim evolve overnight. + private boolean turboMode = false; + private static final int TURBO_SAMPLES_CAP = 600; + private final float[] turboSampleTime = new float[TURBO_SAMPLES_CAP]; + private final int[] turboSampleProto = new int[TURBO_SAMPLES_CAP]; + private final int[] turboSamplePlants = new int[TURBO_SAMPLES_CAP]; + private int turboSampleHead = 0, turboSampleSize = 0; + private float turboLastSampleSimTime = -1f; + private long turboLastLogMs = 0; + private int turboLastLoggedProto = -1; + private com.badlogic.gdx.graphics.glutils.ShapeRenderer turboShape; + + public void toggleTurboMode() { + turboMode = !turboMode; + if (turboMode && turboShape == null) { + turboShape = new com.badlogic.gdx.graphics.glutils.ShapeRenderer(); + } + System.out.println("Turbo mode: " + (turboMode + ? "ON (rendering disabled, max sim throughput)" + : "OFF")); + if (turboMode) { + turboLastLogMs = 0; // force a log on first frame + } + } + + public boolean isTurboMode() { return turboMode; } + + private void turboMaybeSample() { + if (environment == null) return; + float simTime = environment.getElapsedTime(); + if (turboLastSampleSimTime < 0f || simTime - turboLastSampleSimTime >= 1f) { + int proto = environment.numberOfProtozoa(); + int plants = environment.getCount(com.protoevo.biology.cells.PlantCell.class); + int slot = turboSampleHead; + turboSampleTime[slot] = simTime; + turboSampleProto[slot] = proto; + turboSamplePlants[slot] = plants; + turboSampleHead = (turboSampleHead + 1) % TURBO_SAMPLES_CAP; + if (turboSampleSize < TURBO_SAMPLES_CAP) turboSampleSize++; + turboLastSampleSimTime = simTime; + } + } + + private void turboMaybeLog() { + if (environment == null) return; + long now = System.currentTimeMillis(); + int proto = environment.numberOfProtozoa(); + boolean dueByTime = now - turboLastLogMs > 3L * 60L * 1000L; + boolean dueByChange = turboLastLoggedProto > 0 + && Math.abs(proto - turboLastLoggedProto) >= 0.25 * turboLastLoggedProto; + if (turboLastLogMs == 0 || dueByTime || dueByChange) { + int plants = environment.getCount(com.protoevo.biology.cells.PlantCell.class); + // Diagnostic: average protozoan energy / construction-mass / + // food-mass so we can see at a glance whether eating delivers + // anything. If avgFoodMass > 0 but avgConstrMass stays at 0, + // digest isn't converting. If avgFoodMass is also 0, the eat + // path isn't filling the food pool. If construction-mass cap + // is full but cells aren't growing/splitting, the bottleneck + // is elsewhere. + double sumE = 0, sumCM = 0, sumFood = 0, sumR = 0, sumAge = 0; + int n = 0, engulfing = 0; + for (com.protoevo.biology.cells.Cell c : environment.getCells()) { + if (!(c instanceof com.protoevo.biology.cells.Protozoan)) continue; + com.protoevo.biology.cells.Protozoan p = (com.protoevo.biology.cells.Protozoan) c; + if (p.isDead()) continue; + sumE += p.getEnergyAvailable(); + sumCM += p.getConstructionMassAvailable(); + for (com.protoevo.biology.Food f : p.getFoodToDigest().values()) + sumFood += f.getSimpleMass(); + sumR += p.getRadius(); + sumAge += p.getTimeAlive(); + if (!p.getEngulfedCells().isEmpty()) engulfing++; + n++; + } + if (n > 0) { + System.out.printf("[TURBO] sim=%.1fs protozoa=%d plants=%d " + + "avgE=%.1f avgCM=%.5g avgFood=%.5g avgR=%.4f avgAge=%.1fs engulfing=%d%n", + environment.getElapsedTime(), proto, plants, + sumE / n, sumCM / n, sumFood / n, sumR / n, sumAge / n, engulfing); + } else { + System.out.printf("[TURBO] sim=%.1fs protozoa=%d plants=%d (no live protozoa)%n", + environment.getElapsedTime(), proto, plants); + } + turboLastLogMs = now; + turboLastLoggedProto = proto; + } + } + + private void turboRender() { + ScreenUtils.clear(0f, 0f, 0f, 1f); + + // Run sim as hard as we can within ~12ms so the render thread still + // returns in time for libGDX to process input events (so F8 / ESC + // still work). simulation.update() already substeps internally up to + // its safety cap, so each call here advances `timeDilation` substeps. + long deadline = System.currentTimeMillis() + 12; + int iters = 0; + while (System.currentTimeMillis() < deadline) { + simulation.update(); + iters++; + if (iters >= 1000) break; // safety: don't pin the thread on a fast sim + } + + turboMaybeSample(); + turboMaybeLog(); + + // Layout: top 1/8 reserved for status text; rest for line chart. + float w = Gdx.graphics.getWidth(); + float h = Gdx.graphics.getHeight(); + float marginX = w * 0.06f; + float chartTop = h * 0.85f; + float chartBottom = h * 0.10f; + float chartLeft = marginX; + float chartRight = w - marginX; + + // Compute chart bounds. + int n = turboSampleSize; + if (n >= 2) { + float tMin = Float.POSITIVE_INFINITY, tMax = Float.NEGATIVE_INFINITY; + int yMax = 1; + int start = (turboSampleHead - n + TURBO_SAMPLES_CAP) % TURBO_SAMPLES_CAP; + for (int i = 0; i < n; i++) { + int idx = (start + i) % TURBO_SAMPLES_CAP; + float t = turboSampleTime[idx]; + if (t < tMin) tMin = t; + if (t > tMax) tMax = t; + if (turboSampleProto[idx] > yMax) yMax = turboSampleProto[idx]; + if (turboSamplePlants[idx] > yMax) yMax = turboSamplePlants[idx]; + } + float dt = Math.max(1e-3f, tMax - tMin); + + turboShape.begin(com.badlogic.gdx.graphics.glutils.ShapeRenderer.ShapeType.Line); + + // Frame + turboShape.setColor(0.4f, 0.4f, 0.4f, 1f); + turboShape.rect(chartLeft, chartBottom, + chartRight - chartLeft, chartTop - chartBottom); + + // Plot helper inline: protozoa green, plants gray. + for (int series = 0; series < 2; series++) { + if (series == 0) turboShape.setColor(0.4f, 1f, 0.4f, 1f); + else turboShape.setColor(0.55f, 0.55f, 0.55f, 1f); + float prevX = 0, prevY = 0; + for (int i = 0; i < n; i++) { + int idx = (start + i) % TURBO_SAMPLES_CAP; + int v = (series == 0) + ? turboSampleProto[idx] + : turboSamplePlants[idx]; + float fx = (turboSampleTime[idx] - tMin) / dt; + float fy = (float) v / yMax; + float x = chartLeft + fx * (chartRight - chartLeft); + float y = chartBottom + fy * (chartTop - chartBottom); + if (i > 0) turboShape.line(prevX, prevY, x, y); + prevX = x; prevY = y; + } + } + turboShape.end(); + } + + // Status text + legend via the existing UI batch + font. + int proto = environment != null ? environment.numberOfProtozoa() : 0; + int plants = environment != null + ? environment.getCount(com.protoevo.biology.cells.PlantCell.class) : 0; + float simT = environment != null ? environment.getElapsedTime() : 0f; + uiBatch.begin(); + font.setColor(1f, 1f, 1f, 1f); + String top = String.format( + "TURBO MODE | protozoa: %d | plants: %d | sim time: %.1fs | iters/frame: %d", + proto, plants, simT, iters); + font.draw(uiBatch, top, marginX, h - h * 0.04f); + + font.setColor(0.4f, 1f, 0.4f, 1f); + font.draw(uiBatch, "protozoa", marginX, chartTop + 18f); + font.setColor(0.55f, 0.55f, 0.55f, 1f); + font.draw(uiBatch, "plants", marginX + 110f, chartTop + 18f); + + font.setColor(0.6f, 0.6f, 0.6f, 1f); + font.draw(uiBatch, "F8 to exit turbo", marginX, chartBottom - 12f); + uiBatch.end(); + } + // ===== end Turbo mode ===== + + /** + * Low-detail mode: skip the chemical-field overlay and the baked shadow + * texture. Both are screen-filling fragment passes and dominate GPU time + * on weak hardware. Toggles both flags together. + */ + public void toggleLowDetailMode() { + EnvironmentRenderer r = getBaseEnvironmentRenderer(); + boolean nowLow = r.isRenderChemicals() || r.isRenderShadows(); + r.setRenderChemicals(!nowLow); + r.setRenderShadows(!nowLow); + System.out.println("Low-detail mode: " + (nowLow ? "ON (chemicals+shadows hidden)" : "OFF")); + } + public void renderEnvironment(float delta) { float envLight = Functions.clampedLinearRemap( environment.getLightMap().getEnvLight(), @@ -249,6 +697,11 @@ public void renderEnvironment(float delta) { @Override public void render(float delta) { + if (turboMode) { + turboRender(); + return; + } + conditionalTasks.forEach((condition, task) -> { if (condition.get()) task.run(); @@ -281,6 +734,9 @@ public void render(float delta) { topBar.draw(delta); + if (lineageOverlay) + renderLineageOverlay(); + uiBatch.begin(); if (simulation.isBusyOnOtherThread()) @@ -299,6 +755,8 @@ public void render(float delta) { if (DebugMode.isDebugMode()) drawDebugInfo(); + drawSignatureHud(); + uiBatch.end(); stage.setDebugAll(DebugMode.isModeOrHigher(DebugMode.SIMPLE_INFO)); @@ -326,6 +784,206 @@ public void drawSavingText() { font.draw(uiBatch, textWithDots.toString(), x, x); } + // BLAST-style signature HUD with directional, semantic pairing. Plants + // have 1 surface signature; protozoa have 3 (plant-receptor key, + // receiving receptor = identity, phagocytic receptor = what they hunt). + // For two-cell comparison we don't pair by length — we pair by what's + // ecologically meaningful: + // * A.phagocytic ↔ B.receiving ("Can A eat B?") + // * A.receiving ↔ B.phagocytic ("Can B eat A?") + // * A.plantKey ↔ B.plantKey ("Diet overlap") + // Mixed types (protozoa↔plant) compare the protozoa's plant-receptor + // key to the plant's surface signature. + private void drawSignatureHud() { + com.protoevo.physics.Particle tracked = + inputManager.getParticleTracker().getTrackedParticle(); + if (tracked == null) return; + com.protoevo.physics.Particle compared = + inputManager.getParticleTracker().getComparedParticle(); + + float h = font.getLineHeight(); + float x = h; + float y = h * 1.2f; // anchor at bottom, grow upward + + if (compared == null) { + // Single-cell view: list every sequence with its label. + java.util.List rows = signatureRowsFor(tracked); + if (rows.isEmpty()) return; + for (int i = rows.size() - 1; i >= 0; i--) { + String[] row = rows.get(i); + font.draw(uiBatch, row[0] + ": " + row[1], x, y); + y += h; + } + font.draw(uiBatch, + "[ shift+click another cell to BLAST-compare ]", x, y); + return; + } + + // Two-cell view: build the semantic pair list, iterate bottom-up. + java.util.List pairs = buildBlastPairs(tracked, compared); + if (pairs.isEmpty()) return; + for (int i = pairs.size() - 1; i >= 0; i--) { + Pair p = pairs.get(i); + if (p.b == null) { + // No counterpart in B — show A alone. + font.draw(uiBatch, p.aLabel + ": " + p.a, x, y); + y += h; + } else { + // Align marker to where the residues start: 2 ("B ") + + // p.bLabel + 3 (": "). + String prefix = spaces(2 + p.bLabel.length() + 3); + font.draw(uiBatch, "B " + p.bLabel + ": " + p.b, x, y); + y += h; + font.draw(uiBatch, + prefix + matchMarkerLine(p.a, p.b) + + " " + String.format("id %.0f%% run %d", + 100f * identityFraction(p.a, p.b), + longestContiguousMatchLen(p.a, p.b)), + x, y); + y += h; + font.draw(uiBatch, "A " + p.aLabel + ": " + p.a, x, y); + y += h; + // Tag line above the block: what does this pair *mean*? + font.draw(uiBatch, " " + p.description, x, y); + y += h * 1.05f; + } + } + font.draw(uiBatch, "[ A = tracked, B = shift-clicked ]", x, y); + } + + private static final class Pair { + final String aLabel, a, bLabel, b, description; + Pair(String aLabel, String a, String bLabel, String b, String description) { + this.aLabel = aLabel; this.a = a; + this.bLabel = bLabel; this.b = b; + this.description = description; + } + } + + /** Build the semantic pair list for a two-cell HUD comparison. + * Returns the pairs in the order they'll be rendered (top→bottom). */ + private java.util.List buildBlastPairs( + com.protoevo.physics.Particle ap, com.protoevo.physics.Particle bp) { + Object a = ap.getUserData(); + Object b = bp.getUserData(); + java.util.List out = new java.util.ArrayList<>(); + + boolean ap1 = a instanceof com.protoevo.biology.cells.Protozoan; + boolean bp1 = b instanceof com.protoevo.biology.cells.Protozoan; + boolean apl = a instanceof com.protoevo.biology.cells.PlantCell; + boolean bpl = b instanceof com.protoevo.biology.cells.PlantCell; + + if (ap1 && bp1) { + com.protoevo.biology.cells.Protozoan A = (com.protoevo.biology.cells.Protozoan) a; + com.protoevo.biology.cells.Protozoan B = (com.protoevo.biology.cells.Protozoan) b; + String aPhag = str(A.getProtozoaPhagocyticReceptor()); + String aRecv = str(A.getProtozoaReceivingReceptor()); + String aPlnt = str(A.getPlantReceptorKey()); + String bPhag = str(B.getProtozoaPhagocyticReceptor()); + String bRecv = str(B.getProtozoaReceivingReceptor()); + String bPlnt = str(B.getPlantReceptorKey()); + if (aPhag != null && bRecv != null) + out.add(new Pair("Phagocytic (75)", aPhag, + "Receiving (75)", bRecv, "Can A eat B?")); + if (aRecv != null && bPhag != null) + out.add(new Pair("Receiving (75)", aRecv, + "Phagocytic (75)", bPhag, "Can B eat A?")); + if (aPlnt != null && bPlnt != null) + out.add(new Pair("Plant Key (50)", aPlnt, + "Plant Key (50)", bPlnt, "Diet overlap")); + } else if (ap1 && bpl) { + com.protoevo.biology.cells.Protozoan A = (com.protoevo.biology.cells.Protozoan) a; + com.protoevo.biology.cells.PlantCell B = (com.protoevo.biology.cells.PlantCell) b; + String aPlnt = str(A.getPlantReceptorKey()); + String bSurf = str(B.getSurfaceSignature()); + if (aPlnt != null && bSurf != null) + out.add(new Pair("Plant Key (50)", aPlnt, + "Plant Surface(50)", bSurf, "Can A eat this plant?")); + } else if (apl && bp1) { + com.protoevo.biology.cells.PlantCell A = (com.protoevo.biology.cells.PlantCell) a; + com.protoevo.biology.cells.Protozoan B = (com.protoevo.biology.cells.Protozoan) b; + String aSurf = str(A.getSurfaceSignature()); + String bPlnt = str(B.getPlantReceptorKey()); + if (aSurf != null && bPlnt != null) + out.add(new Pair("Plant Surface(50)", aSurf, + "Plant Key (50)", bPlnt, "Can B eat this plant?")); + } else if (apl && bpl) { + com.protoevo.biology.cells.PlantCell A = (com.protoevo.biology.cells.PlantCell) a; + com.protoevo.biology.cells.PlantCell B = (com.protoevo.biology.cells.PlantCell) b; + String aSurf = str(A.getSurfaceSignature()); + String bSurf = str(B.getSurfaceSignature()); + if (aSurf != null && bSurf != null) + out.add(new Pair("Plant Surface(50)", aSurf, + "Plant Surface(50)", bSurf, "Surface similarity")); + } + return out; + } + + private static String str(com.protoevo.biology.evolution.AminoAcidSequence s) { + return s == null ? null : s.toString(); + } + + /** Returns ordered (label, sequence) rows for whatever signatures the + * cell carries. Used in single-cell view. */ + private java.util.List signatureRowsFor(com.protoevo.physics.Particle p) { + java.util.List rows = new java.util.ArrayList<>(); + Object data = p.getUserData(); + if (data instanceof com.protoevo.biology.cells.PlantCell) { + com.protoevo.biology.evolution.AminoAcidSequence s = + ((com.protoevo.biology.cells.PlantCell) data).getSurfaceSignature(); + if (s != null) rows.add(new String[]{"Plant Surface (50)", s.toString()}); + } else if (data instanceof com.protoevo.biology.cells.Protozoan) { + com.protoevo.biology.cells.Protozoan c = + (com.protoevo.biology.cells.Protozoan) data; + com.protoevo.biology.evolution.AminoAcidSequence pk = c.getPlantReceptorKey(); + com.protoevo.biology.evolution.AminoAcidSequence rr = c.getProtozoaReceivingReceptor(); + com.protoevo.biology.evolution.AminoAcidSequence pr = c.getProtozoaPhagocyticReceptor(); + if (pk != null) rows.add(new String[]{"Plant Receptor Key (50)", pk.toString()}); + if (rr != null) rows.add(new String[]{"Receiving Receptor (75)", rr.toString()}); + if (pr != null) rows.add(new String[]{"Phagocytic Receptor(75)", pr.toString()}); + } + return rows; + } + + private static String matchMarkerLine(String a, String b) { + int n = Math.min(a.length(), b.length()); + StringBuilder sb = new StringBuilder(n); + for (int i = 0; i < n; i++) + sb.append(a.charAt(i) == b.charAt(i) ? '|' : '.'); + return sb.toString(); + } + + private static String spaces(int n) { + char[] c = new char[n]; + java.util.Arrays.fill(c, ' '); + return new String(c); + } + + private static float identityFraction(String a, String b) { + int n = Math.min(a.length(), b.length()); + if (n == 0) return 0f; + int match = 0; + for (int i = 0; i < n; i++) if (a.charAt(i) == b.charAt(i)) match++; + return (float) match / n; + } + + /** Length of the longest run of consecutive identical residues. This + * is what the engulf gate actually keys on, so it's more useful for + * the user than overall identity %. */ + private static int longestContiguousMatchLen(String a, String b) { + int n = Math.min(a.length(), b.length()); + int best = 0, cur = 0; + for (int i = 0; i < n; i++) { + if (a.charAt(i) == b.charAt(i)) { + cur++; + if (cur > best) best = cur; + } else { + cur = 0; + } + } + return best; + } + public ImageButton createImageButton(String texturePath, float width, float height, EventListener listener) { Texture texture = ImageUtils.getTexture(texturePath); Drawable drawable = new TextureRegionDrawable(new TextureRegion(texture)); @@ -631,7 +1289,7 @@ public Simulation getSimulation() { public void toggleUI() { uiHidden = !uiHidden; - vignetteLayer.setUiHidden(uiHidden); +// vignetteLayer.setUiHidden(uiHidden); } public boolean hasSimulationNotLoaded() { diff --git a/core/src/com/protoevo/utils/EnvironmentImageRenderer.java b/core/src/com/protoevo/utils/EnvironmentImageRenderer.java index 713ba16..8038fdf 100644 --- a/core/src/com/protoevo/utils/EnvironmentImageRenderer.java +++ b/core/src/com/protoevo/utils/EnvironmentImageRenderer.java @@ -45,27 +45,47 @@ public void background(Graphics2D graphics) graphics.fillRect(0, 0, width, height); ChemicalSolution chemicalSolution = environment.getChemicalSolution(); + if (chemicalSolution == null) + return; + int chemWidth = chemicalSolution.getNXCells(); int chemHeight = chemicalSolution.getNYCells(); - BufferedImage image = new BufferedImage(chemWidth, chemHeight, BufferedImage.TYPE_INT_ARGB); - Graphics2D g = image.createGraphics(); + // Pack the chemical field directly into a raster int[] and write it + // with a single setRGB call. The previous version called + // g.setColor + g.fillRect(1,1) for every chemical cell — at the + // default 1024×1024 resolution that's ~1M Java2D ops, dominating + // save time (often several seconds per save). setRGB on a raw + // int[] is memcpy-fast. + BufferedImage image = new BufferedImage(chemWidth, chemHeight, BufferedImage.TYPE_INT_ARGB); + int[] pixels = new int[chemWidth * chemHeight]; for (int i = 0; i < chemWidth; i++) { for (int j = 0; j < chemHeight; j++) { Colour colour = chemicalSolution.getColour(i, j); - g.setColor(new Color(colour.r, colour.g, colour.b, 0.5f * colour.a)); - g.fillRect(i, chemHeight - j, 1, 1); + int r = clamp255(Math.round(colour.r * 255f)); + int gC = clamp255(Math.round(colour.g * 255f)); + int b = clamp255(Math.round(colour.b * 255f)); + int a = clamp255(Math.round(0.5f * colour.a * 255f)); + int row = chemHeight - 1 - j; // preserve original Y-flip + pixels[row * chemWidth + i] = (a << 24) | (r << 16) | (gC << 8) | b; } } + image.setRGB(0, 0, chemWidth, chemHeight, pixels, 0, chemWidth); int chemImgX = Math.round(toImageSpaceX(chemicalSolution.getMinX())); int chemImgY = Math.round(toImageSpaceY(chemicalSolution.getMinY())); int chemImgWidth = Math.round(toImageDistance(chemicalSolution.getMaxX() - chemicalSolution.getMinX())); int chemImgHeight = Math.round(toImageDistance(chemicalSolution.getMaxY() - chemicalSolution.getMinY())); - + graphics.drawImage(image, chemImgX, chemImgY, chemImgWidth, chemImgHeight, null); } + private static int clamp255(int v) { + if (v < 0) return 0; + if (v > 255) return 255; + return v; + } + public float toImageSpaceX(float worldX) { return (worldX - worldMinX) / (worldMaxX - worldMinX) * width; } diff --git a/desktop/build.gradle b/desktop/build.gradle index 4103fcd..b623b81 100644 --- a/desktop/build.gradle +++ b/desktop/build.gradle @@ -2,8 +2,8 @@ plugins { id 'org.beryx.runtime' version '1.8.4' } apply plugin: "java" //sourceCompatibility = 1.8 -sourceCompatibility = 23 -targetCompatibility = 23 +sourceCompatibility = 21 +targetCompatibility = 21 sourceSets.main.java.srcDirs = [ "src/" ] sourceSets.main.resources.srcDirs = ["../assets"] @@ -20,7 +20,7 @@ task runGame(dependsOn: classes, type: JavaExec) { workingDir = project.assetsDir ignoreExitValue = true - jvmArgs += "-Xmx16G --add-modules jdk.incubator.foreign --illegal-access=permit " + jvmArgs += ["-Xmx8G"] if (OperatingSystem.current() == OperatingSystem.MAC_OS) { // Required to run on macOS diff --git a/run.bat b/run.bat new file mode 100644 index 0000000..aad0c2d --- /dev/null +++ b/run.bat @@ -0,0 +1,13 @@ +@echo off +setlocal +set "JAVA_HOME=C:\Program Files\Microsoft\jdk-21.0.11.10-hotspot" +set "PATH=%JAVA_HOME%\bin;C:\Users\keera\tools\gradle-8.10.2\bin;%PATH%" +cd /d "%~dp0" +rem Stream output to the console AND tee it to run.log so the trace +rem survives a crash that auto-closes the window. Tee-Object is +rem PowerShell's equivalent of unix `tee`. +powershell -NoProfile -Command "& gradle desktop:runGame 2>&1 | Tee-Object -FilePath run.log" +echo. +echo === run.log saved next to run.bat === +endlocal +pause