diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c684276 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,28 @@ +# Ant Simulation - JavaScript Port + +This repository originally contains a Unity project implementing an ant simulation. The goal is to build a JavaScript version that runs directly in the browser. + +## Project Plan + +1. Create a `js` directory to hold the JavaScript implementation. +2. Use vanilla JavaScript and the HTML5 Canvas API for rendering. +3. Mirror the behaviour of the Unity project: ants search for food, leave pheromone trails, and return to the nest. +4. Keep dependencies minimal. Use `npm` to manage any packages. +5. Provide a setup script (`setup.sh`) that prepares the directory structure and checks that Node.js is available. + +## Guidelines + +- Keep JavaScript code in the `js` directory. +- Use ES6 modules and features when possible. +- Document major features and decisions in `js/README.md`. + + +## Task and Progress Tracking + +- Keep a list of upcoming tasks in a "Tasks" section below. +- Maintain a "Progress" section below recording completed work. +- Update both sections as you work on the project. + +### Tasks + +### Progress diff --git a/js/Ant.js b/js/Ant.js new file mode 100644 index 0000000..6205d4f --- /dev/null +++ b/js/Ant.js @@ -0,0 +1,389 @@ +// Depends on Vector2.js, AntSettings.js, PerceptionMap.js, FoodSource.js (indirectly via colony) + +class Ant { + // Enum for Ant states + static State = { + SearchingForFood: 0, + ReturningHome: 1, + // Potentially other states like Fleeing, etc. + }; + + constructor(settings, position, colony, initialForwardDir = null) { + this.settings = settings; // AntSettings instance + this.colony = colony; // Reference to the AntColony instance + + this.currentPosition = position.clone(); + this.homePos = position.clone(); + + this.currentForwardDir = initialForwardDir ? initialForwardDir.normalize() : new Vector2(Math.random() * 2 - 1, Math.random() * 2 - 1).normalize(); + this.currentVelocity = this.currentForwardDir.multiply(settings.maxSpeed); + + this.currentState = Ant.State.SearchingForFood; + + this.deathTime = performance.now() / 1000.0 + settings.lifetime; // Time in seconds + this.timeAlive = 0; + + this.collectedFood = null; // Will hold a FoodItem instance when carrying food + this.targetFood = null; // Target FoodItem + + // Steering forces + this.pheromoneSteerForce = Vector2.zero; + this.randomSteerForce = Vector2.zero; + this.obstacleAvoidForce = Vector2.zero; // Placeholder for now + this.targetSteerForce = Vector2.zero; + + // Turning around state + this.turningAround = false; + this.turnAroundForce = Vector2.zero; + this.turnAroundOriginDir = Vector2.zero; + this.turnAroundTargetDir = Vector2.zero; + this.turnAroundProgress = 0; + this.turnAroundDuration = 0.5; // seconds to complete a 180 turn, adjust as needed + + // Pheromone placement + this.lastPheromonePos = this.currentPosition.clone(); + + // Timers for periodic actions + this.nextRandomSteerTime = 0; + this.nextDirUpdateTime = 0; // For less frequent direction updates (from C#) + + // Time tracking for pheromone strength or other logic + this.leftHomeTime = performance.now() / 1000.0; + this.leftFoodTime = 0; // Will be set when food is collected + + // Max number of entries to retrieve from perception map + this.maxPheromoneResults = 20; + this.pheromoneResults = new Array(this.maxPheromoneResults); + } + + // --- Main Update Method --- + update(deltaTime, foodSources) { // foodSources might be passed via colony or directly + this.timeAlive += deltaTime; + + if (this.settings.useDeath && performance.now() / 1000.0 > this.deathTime) { + // Handle ant death (e.g., remove from simulation, notify colony) + if (this.colony) { + this.colony.removeAnt(this); + } + return; // Skip further updates if dead + } + + // Reset forces that are recalculated each frame + this.targetSteerForce = Vector2.zero; + this.pheromoneSteerForce = Vector2.zero; + // randomSteerForce is handled by its own timer + // obstacleAvoidForce is a placeholder + + this.handlePheromonePlacement(); + this.handleRandomSteering(); + + if (this.turningAround) { + this.continueTurnAround(deltaTime); + } else { + if (this.currentState === Ant.State.SearchingForFood) { + this.handleSearchForFood(foodSources); + } else if (this.currentState === Ant.State.ReturningHome) { + this.handleReturnHome(); + } + } + + this.handleCollisionSteering(); // Placeholder + this.handleMovement(deltaTime); + } + + // --- Behavioral Handlers --- + handleMovement(deltaTime) { + let steerForce = Vector2.zero; + + if (this.turningAround) { + steerForce = steerForce.add(this.turnAroundForce); + } else { + steerForce = steerForce.add(this.targetSteerForce); + steerForce = steerForce.add(this.pheromoneSteerForce); + steerForce = steerForce.add(this.randomSteerForce); + steerForce = steerForce.add(this.obstacleAvoidForce); // Placeholder + } + + // Normalize steerForce if magnitude > 1, then scale by targetSteerStrength + // This prevents individual forces from dominating too much + if (steerForce.magnitude() > 1) { + steerForce = steerForce.normalize(); + } + // The C# version seems to apply targetSteerStrength more selectively. + // For now, a general application, might need refinement. + let desiredVelocity = this.currentForwardDir.multiply(this.settings.maxSpeed).add(steerForce.multiply(this.settings.targetSteerStrength)); + + this.steerTowards(desiredVelocity, deltaTime); + + // Update position + this.currentPosition = this.currentPosition.add(this.currentVelocity.multiply(deltaTime)); + + // Basic boundary collision (example, replace with proper collision later) + // This assumes canvas/world dimensions are known, e.g., via colony or settings + // if (this.colony && this.colony.worldSize) { + // if (this.currentPosition.x < 0 || this.currentPosition.x > this.colony.worldSize.x || + // this.currentPosition.y < 0 || this.currentPosition.y > this.colony.worldSize.y) { + // this.currentForwardDir = this.currentForwardDir.multiply(-1); // Reverse direction + // this.currentPosition = this.currentPosition.add(this.currentVelocity.multiply(deltaTime)); // Move back slightly + // this.startTurnAround(this.currentForwardDir.multiply(-1)); // Turn fully around + // } + // } + } + + steerTowards(desiredVelocity, deltaTime) { + const desiredDir = desiredVelocity.normalize(); + const currentSpeed = this.currentVelocity.magnitude(); + const targetSpeed = Math.min(this.settings.maxSpeed, desiredVelocity.magnitude()); + + // Calculate steering force required to change direction + const steerDir = desiredDir.subtract(this.currentForwardDir); + const steerAccel = steerDir.multiply(this.settings.acceleration * deltaTime); // Simplified: should be based on how much velocity needs to change + + // Update velocity by adding steering acceleration + // This is a simplified model. A more accurate one would consider current velocity vs desired. + this.currentVelocity = this.currentVelocity.add(steerAccel); + + // Clamp speed to maxSpeed + if (this.currentVelocity.magnitude() > this.settings.maxSpeed) { + this.currentVelocity = this.currentVelocity.normalize().multiply(this.settings.maxSpeed); + } + + // Update forward direction + if (this.currentVelocity.magnitude() > 0.001) { // Avoid normalizing zero vector + this.currentForwardDir = this.currentVelocity.normalize(); + } + } + + handleCollisionSteering() { + // Placeholder for obstacle avoidance logic. + // In C#, this uses raycasts from "antennas". + // For JS, we'll need a different strategy (e.g., spatial hashing, simple circle checks). + this.obstacleAvoidForce = Vector2.zero; + + // Example antenna logic (conceptual, needs actual collision detection) + // const leftSensorPos = this.getSensorPosition(Math.PI / 4); // 45 degrees left + // const rightSensorPos = this.getSensorPosition(-Math.PI / 4); // 45 degrees right + // if (this.isCollidingAt(leftSensorPos)) this.obstacleAvoidForce = this.obstacleAvoidForce.add(this.currentForwardDir.rotate(Math.PI / 2).multiply(this.settings.collisionAvoidSteerStrength)); // Turn right + // if (this.isCollidingAt(rightSensorPos)) this.obstacleAvoidForce = this.obstacleAvoidForce.add(this.currentForwardDir.rotate(-Math.PI / 2).multiply(this.settings.collisionAvoidSteerStrength)); // Turn left + } + + handleSearchForFood(foodSources) { // foodSources might be an array or a manager object + if (this.targetFood && this.targetFood.isCollected) { // Check if another ant got it + this.targetFood = null; + } + + if (!this.targetFood) { + // Search for the closest food item within perception radius + let closestFood = null; + let minDistSq = this.settings.perceptionRadius * this.settings.perceptionRadius; + + for (const source of foodSources) { // Assuming foodSources is an array of FoodSource instances + for (const foodItem of source.foodItems) { + const distSq = Vector2.distance(this.currentPosition, foodItem.position) * Vector2.distance(this.currentPosition, foodItem.position); + if (distSq < minDistSq) { + minDistSq = distSq; + closestFood = foodItem; + } + } + } + + if (closestFood) { + this.targetFood = closestFood; + } + } + + if (this.targetFood) { + const dirToFood = this.targetFood.position.subtract(this.currentPosition).normalize(); + this.targetSteerForce = dirToFood.multiply(this.settings.targetSteerStrength); + + // Check if close enough to collect food + const distToFood = Vector2.distance(this.currentPosition, this.targetFood.position); + if (distToFood < (this.settings.collisionRadius + 0.1) ) { // Using collisionRadius as pickup distance + // "Collect" the food + // In C#, FoodSource.ConsumeFood(targetFood) is called by AntManager. + // Here, we'll assume colony or main simulation handles actual removal from FoodSource + if (this.colony.requestFoodConsumption(this.targetFood)) { + this.collectedFood = this.targetFood; // Keep a reference + this.targetFood.isCollected = true; // Mark as collected + this.targetFood = null; + this.currentState = Ant.State.ReturningHome; + this.leftFoodTime = performance.now() / 1000.0; + this.startTurnAround(this.currentForwardDir.multiply(-1)); // Turn around to go home + } else { + // Food was already taken or unavailable + this.targetFood = null; + } + } + } else { + // No target food, rely on pheromone steering + this.handlePheromoneSteering(); + } + } + + handleReturnHome() { + const dirToHome = this.homePos.subtract(this.currentPosition); + const distToHome = dirToHome.magnitude(); + + if (distToHome < this.settings.perceptionRadius * 0.5) { // Prioritize direct return if close + this.targetSteerForce = dirToHome.normalize().multiply(this.settings.targetSteerStrength); + + if (distToHome < (this.settings.collisionRadius + 0.1)) { // CollisionRadius as drop-off distance + if (this.collectedFood) { + // "Drop" food + this.colony.registerCollectedFood(this.collectedFood); // Notify colony + this.collectedFood.isCollected = false; // Mark as available again (or destroy) + this.collectedFood = null; + } + this.currentState = Ant.State.SearchingForFood; + this.leftHomeTime = performance.now() / 1000.0; + this.startTurnAround(this.currentForwardDir.multiply(-1)); // Turn around to search again + } + } else { + // Rely on pheromone steering if home is not immediately in sight or further away + this.handlePheromoneSteering(); + // Add a slight bias towards home even when following pheromones + this.targetSteerForce = this.targetSteerForce.add(dirToHome.normalize().multiply(this.settings.targetSteerStrength * 0.1)); + } + } + + handlePheromonePlacement() { + const distSinceLastMarker = Vector2.distance(this.currentPosition, this.lastPheromonePos); + if (distSinceLastMarker > this.settings.dstBetweenMarkers) { + this.lastPheromonePos = this.currentPosition.clone(); + let weight = 1.0; // Default weight + + if (this.currentState === Ant.State.ReturningHome && this.settings.useFoodMarkers && this.collectedFood) { + // Ant is returning home and has food, places "food" pheromones + // Weight could be based on time since left food source + const timeSinceLeftFood = Math.max(1, (performance.now() / 1000.0) - this.leftFoodTime); + weight = Math.max(0.1, 1 - (timeSinceLeftFood / this.settings.pheromoneRunOutTime)); + if (this.colony.foodMarkers) { + this.colony.foodMarkers.add(this.currentPosition, weight); + } + } else if (this.currentState === Ant.State.SearchingForFood && this.settings.useHomeMarkers) { + // Ant is searching for food, places "home" pheromones + // Weight could be based on time since left home + const timeSinceLeftHome = Math.max(1, (performance.now() / 1000.0) - this.leftHomeTime); + weight = Math.max(0.1, 1 - (timeSinceLeftHome / this.settings.pheromoneRunOutTime)); + if (this.colony.homeMarkers) { + this.colony.homeMarkers.add(this.currentPosition, weight); + } + } + } + } + + handlePheromoneSteering() { + const searchMap = (this.currentState === Ant.State.SearchingForFood) ? this.colony.foodMarkers : this.colony.homeMarkers; + if (!searchMap) return; + + let strongestSignalDir = Vector2.zero; + let totalWeight = 0; + + // Sensor points (simplified: one forward, two angled) + const sensorAngles = [0, Math.PI / 6, -Math.PI / 6]; // Forward, 30deg left, 30deg right + const sensorPositions = sensorAngles.map(angle => this.getSensorPosition(angle, this.settings.sensorDst)); + + // Add current position as a sensor to ensure it picks up nearby signals + sensorPositions.push(this.currentPosition.clone()); + + + for (const sensorPos of sensorPositions) { + // Clear previous results. The C# version passes an array to be filled. + // Here, we'll just get the results and process them. + const entriesInSensorRegion = []; // This should be pre-allocated if possible for performance + searchMap.getAllInCircle(entriesInSensorRegion, sensorPos); // Max results not used here, but could be a param for getAllInCircle + + for (const entry of entriesInSensorRegion) { + const dirToPheromone = entry.position.subtract(this.currentPosition); + const distSq = dirToPheromone.magnitude() * dirToPheromone.magnitude(); + if (distSq > 0) { // Avoid division by zero if ant is on top of pheromone + // Weight by distance (closer pheromones are stronger) and initial weight + const weight = entry.initialWeight / (1 + distSq); // Simple weighting + strongestSignalDir = strongestSignalDir.add(dirToPheromone.normalize().multiply(weight)); + totalWeight += weight; + } + } + } + + if (totalWeight > 0) { + this.pheromoneSteerForce = strongestSignalDir.normalize().multiply(this.settings.pheromoneWeight); + } else { + this.pheromoneSteerForce = Vector2.zero; + } + } + + handleRandomSteering() { + const currentTime = performance.now() / 1000.0; + if (currentTime > this.nextRandomSteerTime && !this.targetFood && !this.turningAround) { + const randomDir = this.getRandomDir(); + this.randomSteerForce = randomDir.multiply(this.settings.randomSteerStrength); + this.nextRandomSteerTime = currentTime + (Math.random() * this.settings.randomSteerMaxDuration); + } else if (this.targetFood || this.turningAround) { + // Stop random steering if there's a specific target or turning + this.randomSteerForce = Vector2.zero; + } + } + + startTurnAround(targetDir) { + if(this.turningAround) return; // Already turning + + this.turningAround = true; + this.turnAroundOriginDir = this.currentForwardDir.clone(); + this.turnAroundTargetDir = targetDir.normalize(); + this.turnAroundProgress = 0; + + // Calculate turn duration based on angle to turn (e.g. 0.5s for 180 deg) + const angle = Math.acos(this.turnAroundOriginDir.dot(this.turnAroundTargetDir)); // Radians + this.turnAroundDuration = (angle / Math.PI) * 0.5; // Proportional to 180 deg turn time + + if (this.turnAroundDuration < 0.01) { // Already facing target + this.turningAround = false; + this.currentForwardDir = this.turnAroundTargetDir.clone(); + this.turnAroundForce = Vector2.zero; + } + } + + continueTurnAround(deltaTime) { + if (!this.turningAround) return; + + this.turnAroundProgress += deltaTime / this.turnAroundDuration; + + if (this.turnAroundProgress >= 1) { + this.turningAround = false; + this.currentForwardDir = this.turnAroundTargetDir.clone(); + this.turnAroundForce = Vector2.zero; + } else { + // Interpolate direction (Slerp would be better, LERP for now) + const newDirX = this.turnAroundOriginDir.x + (this.turnAroundTargetDir.x - this.turnAroundOriginDir.x) * this.turnAroundProgress; + const newDirY = this.turnAroundOriginDir.y + (this.turnAroundTargetDir.y - this.turnAroundOriginDir.y) * this.turnAroundProgress; + this.currentForwardDir = new Vector2(newDirX, newDirY).normalize(); + + // Apply a force to make the turn happen via steering mechanism + this.turnAroundForce = this.currentForwardDir.subtract(this.currentVelocity.normalize()).normalize().multiply(this.settings.targetSteerStrength * 2); // Stronger force for turning + } + } + + + // --- Helper Methods --- + getSensorPosition(angleOffset, distance) { // Angle relative to currentForwardDir + const dirX = this.currentForwardDir.x * Math.cos(angleOffset) - this.currentForwardDir.y * Math.sin(angleOffset); + const dirY = this.currentForwardDir.x * Math.sin(angleOffset) + this.currentForwardDir.y * Math.cos(angleOffset); + return this.currentPosition.add(new Vector2(dirX, dirY).multiply(distance)); + } + + getRandomDir() { + const angle = Math.random() * 2 * Math.PI; + return new Vector2(Math.cos(angle), Math.sin(angle)); + } + + // Placeholder for checking collision at a point (used by conceptual handleCollisionSteering) + // isCollidingAt(point) { + // // In a real scenario, this would check against world geometry / other ants + // return false; + // } +} + +// Export for use in other modules if using a module system (e.g., ES6 modules) +if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { + module.exports = Ant; +} diff --git a/js/AntColony.js b/js/AntColony.js new file mode 100644 index 0000000..31aea72 --- /dev/null +++ b/js/AntColony.js @@ -0,0 +1,127 @@ +// Depends on Vector2.js, AntSettings.js, PerceptionMap.js, Ant.js + +class AntColony { + constructor({ + settings, // AntSettings instance + position = new Vector2(0, 0), // Colony center position + radius = 2, // Radius of the colony nest area + numToSpawn = 50, + replenishDead = true, + worldSize = { x: 80, y: 60 } // Default world size for perception maps + }) { + this.settings = settings; + this.position = position; + this.radius = radius; // For spawning ants within this area, and as home base + this.numToSpawn = numToSpawn; + this.replenishDead = replenishDead; + this.worldSize = worldSize; // Used for PerceptionMap area + + this.ants = []; + this.numFoodCollected = 0; + this.timePassed = 0; // Optional, as in C# + + // Initialize PerceptionMaps for pheromones + // The area for these maps should ideally cover the entire simulation space. + this.homeMarkers = new PerceptionMap(this.worldSize, this.settings); + this.foodMarkers = new PerceptionMap(this.worldSize, this.settings); + + // Spawn initial ants + this.init(); + } + + init() { + for (let i = 0; i < this.numToSpawn; i++) { + this.spawnAnt(); + } + } + + update(deltaTime, foodSources) { // foodSources will be an array of FoodSource instances + this.timePassed += deltaTime; + + // Update all ants + // Iterate backwards to allow safe removal of ants during the loop + for (let i = this.ants.length - 1; i >= 0; i--) { + const ant = this.ants[i]; + ant.update(deltaTime, foodSources); + // Ant death is handled within ant.update by calling colony.removeAnt(this) + } + + // Replenish dead ants + if (this.replenishDead && this.ants.length < this.numToSpawn) { + this.spawnAnt(); + } + } + + spawnAnt() { + // Spawn ant at a random position within the colony's radius + const angle = Math.random() * Math.PI * 2; + const dist = Math.random() * this.radius; + const spawnPos = this.position.add(new Vector2(Math.cos(angle) * dist, Math.sin(angle) * dist)); + + // Random initial forward direction + const randomAngle = Math.random() * Math.PI * 2; + const initialForwardDir = new Vector2(Math.cos(randomAngle), Math.sin(randomAngle)); + + const newAnt = new Ant(this.settings, spawnPos, this, initialForwardDir); + this.ants.push(newAnt); + } + + // Called by an Ant when it successfully "drops" food at the colony + registerCollectedFood(foodItem) { + this.numFoodCollected++; + // UI update would happen elsewhere, e.g., in a main simulation render loop + // console.log(`Food collected! Total: ${this.numFoodCollected}`); + } + + // Called by an Ant when it "collects" food from a FoodSource + // This allows the colony to manage access to food if necessary, + // or simply pass the request to the food source. + requestFoodConsumption(foodItem) { + // Find the food source that owns this foodItem + // This is a bit inefficient; ideally, foodItem would know its source or be globally unique. + // For now, assuming foodSources is available and not too large. + // In the C# version, AntManager calls FoodSource.ConsumeFood. + // Here, the Ant asks the Colony, which might ask the FoodSource. + + // Simplified: find the food source that contains this food item and tell it to consume it. + // This requires foodSources to be accessible here, or a different pattern. + // Let's assume `foodSources` is passed to `update` and available if needed, + // but for now, the Ant directly marks food as `isCollected`. The actual removal from + // the FoodSource's list might need to happen in the main loop or via the Ant itself + // if it holds a reference to the FoodSource. + // For now, we'll assume the Ant handles its `targetFood.isCollected = true` + // and the FoodSource might later clean up collected items or the Ant tells it. + + // Let's refine this: The Ant has a targetFood. It should ask that FoodSource to consume it. + // The Ant should call targetFood.source.consumeFood(targetFood). + // This method in Colony might not be strictly necessary if Ants manage consumption directly. + // However, if the Colony needs to mediate or track this, it can. + + // For this port, let's assume the Ant has already marked the food as "collected" (isCollected = true). + // The colony's role here is mostly to acknowledge. + // The actual removal from the FoodSource's list can be handled by the FoodSource itself + // when an ant tries to pick up an already collected item, or periodically. + + // This method is less critical if ants directly interact with FoodSource for consumption. + // Let's keep it simple for now. The critical part is `registerCollectedFood`. + // C# AntManager calls foodSource.ConsumeFood(ant.TargetFood); + // So, the ant should probably tell the food source directly. + // Let's assume `ant.collectFood()` handles this interaction. + // This method can return true to confirm consumption is allowed/successful. + return true; // Placeholder, actual consumption logic is more complex. + } + + // Called by an Ant when it dies + removeAnt(antToRemove) { + const index = this.ants.indexOf(antToRemove); + if (index > -1) { + this.ants.splice(index, 1); + // console.log("Ant died and was removed from colony."); + } + } +} + +// Export for use in other modules if using a module system (e.g., ES6 modules) +if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { + module.exports = AntColony; +} diff --git a/js/AntSettings.js b/js/AntSettings.js new file mode 100644 index 0000000..a4f8844 --- /dev/null +++ b/js/AntSettings.js @@ -0,0 +1,37 @@ +const AntSettings = { + // Movement + maxSpeed: 2, + acceleration: 3, + collisionAvoidSteerStrength: 5, + targetSteerStrength: 3, + randomSteerStrength: 0.6, + randomSteerMaxDuration: 1, + timeBetweenDirUpdate: 0.15, + collisionRadius: 0.15, + + // Pheromones + dstBetweenMarkers: 0.75, + pheromoneEvaporateTime: 45, + pheromoneRunOutTime: 30, + pheromoneWeight: 1, + perceptionRadius: 2.5, + useHomeMarkers: true, + useFoodMarkers: true, + + // Sensing + sensorSize: 0.75, + sensorDst: 1.25, + sensorSpacing: 1, + antennaDst: 0.25, + + // Lifetime + lifetime: 150, + useDeath: false, +}; + +// Export for use in other modules if using a module system (e.g., ES6 modules) +// For simple browser environments, this will make AntSettings globally available. +// If you intend to use ES6 modules, you would use: export default AntSettings; +if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { + module.exports = AntSettings; +} diff --git a/js/FoodSource.js b/js/FoodSource.js new file mode 100644 index 0000000..89223c3 --- /dev/null +++ b/js/FoodSource.js @@ -0,0 +1,113 @@ +// Depends on Vector2.js + +class FoodItem { + constructor(position) { + this.position = position; // Vector2 + this.size = 5; // Default size, can be customized + // In a real scenario, this might hold more info (e.g., graphic, type) + } +} + +class FoodSource { + constructor({ + position = new Vector2(0, 0), + radius = 10, + // foodPrefab is conceptual here; we'll create FoodItem instances + timeBetweenSpawns = 1, // Not directly used if spawning is driven by update/maintainAmount + amount = 50, + maintainAmount = true, + blobCount = 3, + seed = 0 // Note: Math.random() in JS is not directly seedable. This seed is for potential future PRNG. + } = {}) { + this.position = position; // Center position of the food source area + this.radius = radius; + this.amount = amount; // Target number of food items + this.maintainAmount = maintainAmount; + this.blobCount = blobCount; + this.seed = seed; // Store seed + + // Simple PRNG (Linear Congruential Generator - LCG) + // Parameters from https://en.wikipedia.org/wiki/Linear_congruential_generator#Parameters_in_common_use + this.prng_m = 0x80000000; // 2^31 + this.prng_a = 1103515245; + this.prng_c = 12345; + this.prng_state = seed; + + this.foodItems = []; + this.blobs = []; + + this.initBlobs(); + + // Initial spawn + for (let i = 0; i < this.amount; i++) { + this.spawnFood(); + } + } + + // Simple LCG random number generator (returns value between 0 and 1) + random() { + this.prng_state = (this.prng_a * this.prng_state + this.prng_c) % this.prng_m; + return this.prng_state / (this.prng_m -1); + } + + initBlobs() { + const blobRadiusMin = this.radius * 0.1; + const blobRadiusMax = this.radius * 0.4; + + for (let i = 0; i < this.blobCount; i++) { + const angle = this.random() * Math.PI * 2; + const dist = this.random() * this.radius * 0.75; // Place blobs not too close to the edge + const blobPos = this.position.add(new Vector2(Math.cos(angle) * dist, Math.sin(angle) * dist)); + const blobRadius = blobRadiusMin + this.random() * (blobRadiusMax - blobRadiusMin); + this.blobs.push({ position: blobPos, radius: blobRadius }); + } + // If no blobs, make the source itself a blob + if (this.blobs.length === 0) { + this.blobs.push({ position: this.position.clone(), radius: this.radius }); + } + } + + spawnFood() { + if (this.blobs.length === 0) { + console.error("No blobs to spawn food in."); + return null; + } + + // Pick a random blob + const blobIndex = Math.floor(this.random() * this.blobs.length); + const selectedBlob = this.blobs[blobIndex]; + + // Generate a random point within the selected blob's radius + const angle = this.random() * Math.PI * 2; + const dist = this.random() * selectedBlob.radius; + const spawnPos = selectedBlob.position.add(new Vector2(Math.cos(angle) * dist, Math.sin(angle) * dist)); + + const newFood = new FoodItem(spawnPos); + this.foodItems.push(newFood); + return newFood; + } + + // update method to maintain food amount + update() { + if (this.maintainAmount && this.foodItems.length < this.amount) { + this.spawnFood(); + } + // In a simulation, this might also handle food consumption, etc. + } + + // Method to consume food, e.g., by an ant + consumeFood(foodItem) { + const index = this.foodItems.indexOf(foodItem); + if (index > -1) { + this.foodItems.splice(index, 1); + return true; // Food consumed + } + return false; // Food not found + } +} + +// Export for use in other modules if using a module system (e.g., ES6 modules) +// For simple browser environments, this will make FoodSource and FoodItem globally available. +if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { + module.exports = { FoodSource, FoodItem }; +} diff --git a/js/PerceptionMap.js b/js/PerceptionMap.js new file mode 100644 index 0000000..53f600f --- /dev/null +++ b/js/PerceptionMap.js @@ -0,0 +1,140 @@ +// Depends on Vector2.js and AntSettings.js + +class PerceptionMap { + // Entry structure (can be a simple object literal, or a class if preferred) + static Entry = class { + constructor(position, creationTime, initialWeight) { + this.position = position; // Vector2 + this.creationTime = creationTime; // seconds + this.initialWeight = initialWeight; + } + } + + // Cell class + static Cell = class { + constructor() { + this.entries = []; // Using an array for entries, similar to LinkedList + } + + add(entry) { + this.entries.push(entry); + } + + // Optional: Method to remove an entry if needed, though C# version removes during iteration + remove(entry) { + const index = this.entries.indexOf(entry); + if (index > -1) { + this.entries.splice(index, 1); + } + } + } + + constructor(area, antSettings) { + this.area = area; // { x: width, y: height } + this.antSettings = antSettings; + + // Initialize properties based on antSettings + const perceptionRadius = Math.max(0.01, this.antSettings.sensorSize); + this.sqrPerceptionRadius = perceptionRadius * perceptionRadius; + + this.numCellsX = Math.ceil(this.area.x / perceptionRadius); + this.numCellsY = Math.ceil(this.area.y / perceptionRadius); + + // Using Vector2 for halfSize for consistency, though it's just a pair of numbers here + this.halfSize = new Vector2(this.numCellsX * perceptionRadius, this.numCellsY * perceptionRadius).multiply(0.5); + this.cellSizeReciprocal = 1 / perceptionRadius; + + // Initialize the cells grid + this.cells = []; + for (let x = 0; x < this.numCellsX; x++) { + this.cells[x] = []; + for (let y = 0; y < this.numCellsY; y++) { + this.cells[x][y] = new PerceptionMap.Cell(); + } + } + // Skipping particle display initialization for now + } + + /** + * Translates a world position (point) to cell grid coordinates. + * @param {Vector2} point The world position. + * @returns {{x: number, y: number}} The cell coordinates. + */ + cellCoordFromPos(point) { + let x = Math.floor((point.x + this.halfSize.x) * this.cellSizeReciprocal); + let y = Math.floor((point.y + this.halfSize.y) * this.cellSizeReciprocal); + // Clamp coordinates to be within grid bounds + x = Math.max(0, Math.min(x, this.numCellsX - 1)); + y = Math.max(0, Math.min(y, this.numCellsY - 1)); + return { x, y }; + } + + /** + * Adds a pheromone entry at a given point. + * @param {Vector2} point The position to add the entry. + * @param {number} initialWeight The initial weight of the pheromone. + */ + add(point, initialWeight) { + const cellCoord = this.cellCoordFromPos(point); + const cell = this.cells[cellCoord.x][cellCoord.y]; + + const currentTime = performance.now() / 1000.0; // Time in seconds + const entry = new PerceptionMap.Entry(point, currentTime, initialWeight); + cell.add(entry); + + // Particle display logic skipped as per instructions + } + + /** + * Gets all entries within a perception circle. + * @param {Array} resultArray An array to store the results. + * @param {Vector2} centre The centre of the perception circle. + * @returns {number} The number of entries found and added to resultArray. + */ + getAllInCircle(resultArray, centre) { + const cellCoord = this.cellCoordFromPos(centre); + let count = 0; + const currentTime = performance.now() / 1000.0; // Time in seconds + + for (let offsetY = -1; offsetY <= 1; offsetY++) { + for (let offsetX = -1; offsetX <= 1; offsetX++) { + const cellX = cellCoord.x + offsetX; + const cellY = cellCoord.y + offsetY; + + if (cellX >= 0 && cellX < this.numCellsX && cellY >= 0 && cellY < this.numCellsY) { + const cell = this.cells[cellX][cellY]; + const remainingEntries = []; // To store entries that are not expired + + for (let i = 0; i < cell.entries.length; i++) { + const entry = cell.entries[i]; + const currentLifetime = currentTime - entry.creationTime; + + if (currentLifetime > this.antSettings.pheromoneEvaporateTime) { + // Entry has expired, do not add to remainingEntries (effectively removing it) + continue; + } + remainingEntries.push(entry); // Keep non-expired entry + + // Check if entry is inside perception radius (using squared distance for efficiency) + if (centre.subtract(entry.position).magnitude() * centre.subtract(entry.position).magnitude() < this.sqrPerceptionRadius) { + // The C# version expects resultArray to be pre-sized. + // For JS, we can push, but for compatibility with the C# design, + // we might want to respect a max size if resultArray had one. + // For now, just push. + resultArray.push(entry); + count++; + } + } + cell.entries = remainingEntries; // Update cell entries to only non-expired ones + } + } + } + return count; + } +} + +// Export for use in other modules if using a module system (e.g., ES6 modules) +// For simple browser environments, this will make PerceptionMap globally available. +if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { + module.exports = PerceptionMap; +} diff --git a/js/README.md b/js/README.md new file mode 100644 index 0000000..1dee347 --- /dev/null +++ b/js/README.md @@ -0,0 +1 @@ +This directory contains the JavaScript port of the Unity ant simulation. Work in progress. diff --git a/js/Renderer.js b/js/Renderer.js new file mode 100644 index 0000000..efc7fc6 --- /dev/null +++ b/js/Renderer.js @@ -0,0 +1,151 @@ +// Depends on Vector2.js, Ant.js, FoodSource.js, AntColony.js, PerceptionMap.js + +class Renderer { + constructor(canvas, worldSize) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.worldSize = worldSize; // e.g., { width, height } or { x, y } + + if (this.worldSize.width && this.worldSize.height) { + this.canvas.width = this.worldSize.width; + this.canvas.height = this.worldSize.height; + } else if (this.worldSize.x && this.worldSize.y) { // Support {x,y} too + this.canvas.width = this.worldSize.x; + this.canvas.height = this.worldSize.y; + } else { + console.error("worldSize must have width/height or x/y properties."); + } + } + + clear() { + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + } + + drawAnt(ant) { + this.ctx.save(); + this.ctx.translate(ant.currentPosition.x, ant.currentPosition.y); + this.ctx.rotate(Math.atan2(ant.currentForwardDir.y, ant.currentForwardDir.x)); + + // Body + if (ant.collectedFood) { + this.ctx.fillStyle = 'green'; // Carrying food + } else if (ant.currentState === Ant.State.SearchingForFood) { + this.ctx.fillStyle = 'black'; + } else { + this.ctx.fillStyle = '#333'; // Returning home, no food (dark grey) + } + + this.ctx.beginPath(); + // Simple triangle shape for the ant body + const antSize = 4; // pixels + this.ctx.moveTo(antSize, 0); + this.ctx.lineTo(-antSize / 2, antSize / 2); + this.ctx.lineTo(-antSize / 2, -antSize / 2); + this.ctx.closePath(); + this.ctx.fill(); + + // Optional: Head + // this.ctx.fillStyle = 'grey'; + // this.ctx.beginPath(); + // this.ctx.arc(antSize * 0.75, 0, antSize / 3, 0, Math.PI * 2); + // this.ctx.fill(); + + this.ctx.restore(); + } + + drawFoodSource(foodSource) { + // Optional: Draw the radius of the food source area + this.ctx.strokeStyle = 'rgba(0, 100, 0, 0.2)'; // Light green for area + this.ctx.lineWidth = 1; + this.ctx.beginPath(); + this.ctx.arc(foodSource.position.x, foodSource.position.y, foodSource.radius, 0, Math.PI * 2); + this.ctx.stroke(); + + // Draw individual food items + this.ctx.fillStyle = 'rgb(100, 200, 0)'; // Brighter green for food items + for (const foodItem of foodSource.foodItems) { + if (!foodItem.isCollected) { // Only draw if not collected + this.ctx.beginPath(); + this.ctx.arc(foodItem.position.x, foodItem.position.y, foodItem.size / 2, 0, Math.PI * 2); + this.ctx.fill(); + } + } + } + + drawColony(colony) { + this.ctx.fillStyle = 'saddlebrown'; // Anthill color + this.ctx.beginPath(); + this.ctx.arc(colony.position.x, colony.position.y, colony.radius, 0, Math.PI * 2); + this.ctx.fill(); + + // Optional: Entrance + this.ctx.fillStyle = 'black'; + this.ctx.beginPath(); + this.ctx.arc(colony.position.x, colony.position.y, colony.radius / 3, 0, Math.PI * 2); + this.ctx.fill(); + } + + drawPheromones(perceptionMap, baseColor, antSettings) { + const currentTime = performance.now() / 1000.0; // seconds + + for (let x = 0; x < perceptionMap.numCellsX; x++) { + for (let y = 0; y < perceptionMap.numCellsY; y++) { + const cell = perceptionMap.cells[x][y]; + for (const entry of cell.entries) { + const age = currentTime - entry.creationTime; + if (age < antSettings.pheromoneEvaporateTime) { + const alpha = entry.initialWeight * (1 - (age / antSettings.pheromoneEvaporateTime)); + if (alpha < 0.01) continue; // Don't draw very faint pheromones + + // Convert baseColor (e.g., 'blue', 'red') to rgba + let r=0, g=0, b=0; + if (baseColor === 'blue') { r=0; g=0; b=255; } + else if (baseColor === 'red') { r=255; g=0; b=0; } + // Add more colors if needed + + this.ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${alpha})`; + this.ctx.beginPath(); + this.ctx.arc(entry.position.x, entry.position.y, 1.5, 0, Math.PI * 2); // Pheromone dot size + this.ctx.fill(); + } + } + } + } + } + + render(colony, foodSources, antSettings) { + this.clear(); + + // Draw colony (anthill) + if (colony) { + this.drawColony(colony); + + // Draw pheromones + if (colony.homeMarkers && antSettings.useHomeMarkers) { + this.drawPheromones(colony.homeMarkers, 'blue', antSettings); + } + if (colony.foodMarkers && antSettings.useFoodMarkers) { + this.drawPheromones(colony.foodMarkers, 'red', antSettings); + } + } + + // Draw food sources + if (foodSources) { + for (const fs of foodSources) { + this.drawFoodSource(fs); + } + } + + // Draw ants + if (colony && colony.ants) { + for (const ant of colony.ants) { + this.drawAnt(ant); + } + } + } +} + +// Export for use in other modules if using a module system (e.g., ES6 modules) +if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { + module.exports = Renderer; +} diff --git a/js/Vector2.js b/js/Vector2.js new file mode 100644 index 0000000..b4cca5f --- /dev/null +++ b/js/Vector2.js @@ -0,0 +1,90 @@ +class Vector2 { + constructor(x = 0, y = 0) { + this.x = x; + this.y = y; + } + + add(otherVector) { + return new Vector2(this.x + otherVector.x, this.y + otherVector.y); + } + + subtract(otherVector) { + return new Vector2(this.x - otherVector.x, this.y - otherVector.y); + } + + multiply(scalar) { + return new Vector2(this.x * scalar, this.y * scalar); + } + + divide(scalar) { + if (scalar === 0) { + console.error("Cannot divide by zero."); + return new Vector2(this.x, this.y); // Or throw an error, or return (Infinity, Infinity) + } + return new Vector2(this.x / scalar, this.y / scalar); + } + + magnitude() { + return Math.sqrt(this.x * this.x + this.y * this.y); + } + + normalize() { + const mag = this.magnitude(); + if (mag === 0) { + return new Vector2(0, 0); // Or handle as an error + } + return this.divide(mag); + } + + dot(otherVector) { + return this.x * otherVector.x + this.y * otherVector.y; + } + + clone() { + return new Vector2(this.x, this.y); + } + + // Optional: Static methods for convenience + static get zero() { + return new Vector2(0, 0); + } + + static get one() { + return new Vector2(1, 1); + } + + static get up() { + return new Vector2(0, 1); // Assuming +y is up + } + + static get down() { + return new Vector2(0, -1); // Assuming -y is down + } + + static get left() { + return new Vector2(-1, 0); + } + + static get right() { + return new Vector2(1, 0); + } + + static distance(vecA, vecB) { + return vecA.subtract(vecB).magnitude(); + } + + static angle(vecA, vecB) { // Returns angle in radians + const dotProduct = vecA.dot(vecB); + const magA = vecA.magnitude(); + const magB = vecB.magnitude(); + if (magA === 0 || magB === 0) return 0; // Or handle error + return Math.acos(dotProduct / (magA * magB)); + } +} + +// Export for use in other modules if using a module system (e.g., ES6 modules) +// For simple browser environments, this will make Vector2 globally available. +// If you intend to use ES6 modules, you would use: export default Vector2; +if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { + module.exports = Vector2; +} diff --git a/js/index.html b/js/index.html new file mode 100644 index 0000000..3148d42 --- /dev/null +++ b/js/index.html @@ -0,0 +1,32 @@ + + + + + + Ant Simulation + + + + + + + + + + + + + + diff --git a/js/main.js b/js/main.js new file mode 100644 index 0000000..9180108 --- /dev/null +++ b/js/main.js @@ -0,0 +1,78 @@ +// Basic structure: +document.addEventListener('DOMContentLoaded', () => { + const WORLD_SIZE = { width: 800, height: 600 }; // Or get from canvas attributes + const canvas = document.getElementById('simulationCanvas'); + // Ensure canvas is sized if not by CSS + canvas.width = WORLD_SIZE.width; + canvas.height = WORLD_SIZE.height; + + // Assuming AntSettings is an object literal as defined earlier + const antSettings = AntSettings; // If it's a global object + + const renderer = new Renderer(canvas, WORLD_SIZE); + + const colonyPosition = new Vector2(WORLD_SIZE.width / 2, WORLD_SIZE.height / 2); + const colonyRadius = 30; // Example radius + const initialAntCount = 50; // Example count + const replenishAnts = true; + // AntColony constructor: ({ settings, position, radius, numToSpawn, replenishDead, worldSize }) + const colony = new AntColony({ + settings: antSettings, + position: colonyPosition, + radius: colonyRadius, + numToSpawn: initialAntCount, + replenishDead: replenishAnts, + worldSize: WORLD_SIZE + }); + + const foodSources = []; + // FoodSource constructor: ({ position, radius, amount, maintainAmount, blobCount, seed, worldSize }) + // Example: Place food source in top-left quadrant + foodSources.push(new FoodSource({ + position: new Vector2(WORLD_SIZE.width * 0.25, WORLD_SIZE.height * 0.25), + radius: 50, + amount: 75, + maintainAmount: true, + blobCount: 3, + seed: Date.now(), // Use current time for variety + // worldSize parameter is not explicitly in the FoodSource constructor's destructuring, + // but it's used internally if needed for other things. Not critical for its current implementation. + })); + // Example: Place another food source in bottom-right quadrant + foodSources.push(new FoodSource({ + position: new Vector2(WORLD_SIZE.width * 0.75, WORLD_SIZE.height * 0.75), + radius: 60, + amount: 100, + maintainAmount: true, + blobCount: 5, + seed: Date.now() + 1, // Use current time + 1 for variety + })); + + + let lastTime = 0; + function gameLoop(currentTime) { + if (!lastTime) { + lastTime = currentTime; + } + // Cap deltaTime to prevent large jumps if the tab loses focus or for performance spikes + const deltaTime = Math.min((currentTime - lastTime) / 1000, 0.1); + lastTime = currentTime; + + // Update logic + colony.update(deltaTime, foodSources); // Pass foodSources for ants to detect + foodSources.forEach(fs => fs.update(deltaTime)); // For replenishment + + // Render + renderer.render(colony, foodSources, antSettings); + + requestAnimationFrame(gameLoop); + } + + // Start the simulation + requestAnimationFrame(gameLoop); + console.log('Ant simulation started. WORLD_SIZE:', WORLD_SIZE); + console.log('AntSettings:', antSettings); + console.log('Colony:', colony); + console.log('FoodSources:', foodSources); + console.log('Renderer:', renderer); +}); diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..1303ced --- /dev/null +++ b/setup.sh @@ -0,0 +1,40 @@ +#!/bin/bash +# Setup script for the JavaScript version of the Ant Simulation +set -e + +if ! command -v node >/dev/null 2>&1; then + echo "Node.js is required but not installed. Please install Node.js and rerun." >&2 + exit 1 +fi + +JS_DIR="js" + +if [ ! -d "$JS_DIR" ]; then + mkdir "$JS_DIR" + cat > "$JS_DIR/index.html" <<'EOT' + + + + + Ant Simulation JS + + + + + + +EOT + + cat > "$JS_DIR/main.js" <<'EOT' +// Entry point for the JavaScript ant simulation +console.log('Ant simulation placeholder'); +EOT + + cat > "$JS_DIR/README.md" <<'EOT' +# JavaScript Ant Simulation + +This directory will contain the JavaScript port of the ant simulation. The included files are placeholders. +EOT +fi + +echo "Setup complete. Files created in $JS_DIR/"