7 phases: config changes, world seeding, ant physics (gravity/surface/collision), priority stack brain, unified brush with ant spawning, sand color variation, and cleanup. Future phases B (digging pheromone) and C (colony dynamics) captured as hooks.
1039 lines
33 KiB
Markdown
1039 lines
33 KiB
Markdown
# Ant Behavior Overhaul — Implementation Plan
|
|
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
|
|
**Goal:** Fix broken ant physics (gravity, surface movement, collision), replace the ant count system with a budget/activation model, unify the brush tool so ants are droppable like materials, rewrite the ant brain as a priority stack, and add sand color variation.
|
|
|
|
**Architecture:** Ant physics adds gravity and surface constraints to antsCompute.frag. Ant texture switches from power-of-2 sizing to a fixed budget pool with activation tracking. The brush tool gains an "ants" element that activates pooled ants at click positions. The ant behavioral priority stack replaces the current flat foraging logic with fall→deposit→navigate→forage→dig→wander.
|
|
|
|
**Tech Stack:** three.js (WebGL2), GLSL3 fragment shaders, TypeScript, bun test, biome
|
|
|
|
**Design doc:** `docs/plans/2026-03-11-ant-behavior-overhaul-design.md`
|
|
|
|
---
|
|
|
|
## Phase 1: Config and Constants
|
|
|
|
Foundation layer. New config values, ant texture sizing changes.
|
|
|
|
### Task 1.1: Replace antsCount with antsStartCount and antBudget
|
|
|
|
**Files:**
|
|
- Modify: `src/Config.ts`
|
|
- Test: `src/__tests__/constants.test.ts` (extend)
|
|
|
|
**Step 1: Write failing tests**
|
|
|
|
```typescript
|
|
// add to src/__tests__/constants.test.ts
|
|
import { describe, expect, test } from "bun:test";
|
|
import { defaults } from "../Config";
|
|
|
|
describe("config defaults", () => {
|
|
test("antsStartCount is a direct count, not an exponent", () => {
|
|
expect(defaults.antsStartCount).toBe(4);
|
|
expect(defaults.antsStartCount).toBeLessThanOrEqual(64);
|
|
});
|
|
|
|
test("antBudget determines texture pool size", () => {
|
|
expect(defaults.antBudget).toBe(512);
|
|
});
|
|
|
|
test("seedWorld defaults to true", () => {
|
|
expect(defaults.seedWorld).toBe(true);
|
|
});
|
|
|
|
test("old antsCount key does not exist", () => {
|
|
expect("antsCount" in defaults).toBe(false);
|
|
});
|
|
});
|
|
```
|
|
|
|
Run: `bun test src/__tests__/constants.test.ts`
|
|
Expected: FAIL — antsStartCount not found, antsCount still exists
|
|
|
|
**Step 2: Update Config.ts**
|
|
|
|
Replace `antsCount: 12` with:
|
|
|
|
```typescript
|
|
antsStartCount: 4,
|
|
antBudget: 512,
|
|
seedWorld: true,
|
|
```
|
|
|
|
Remove `antsCount` entirely. Keep `brushMaterial` for now (removed in a later task).
|
|
|
|
Run: `bun test src/__tests__/constants.test.ts`
|
|
Expected: FAIL — TypeScript errors in other files referencing `Config.antsCount`
|
|
|
|
**Step 3: Update Renderer.ts ant texture sizing**
|
|
|
|
In `src/Renderer.ts`, replace all references to `Config.antsCount`:
|
|
|
|
```typescript
|
|
// initResources() and reset() — old:
|
|
const antTextureSize = Math.round(Math.sqrt(2 ** Config.antsCount));
|
|
// new:
|
|
const antTextureSize = Math.ceil(Math.sqrt(Config.antBudget));
|
|
```
|
|
|
|
There are two occurrences: `initResources()` (line 75) and `reset()` (line 328).
|
|
|
|
**Step 4: Update GUI.ts**
|
|
|
|
Replace the ant count slider:
|
|
|
|
```typescript
|
|
// old:
|
|
simFolder.add(Config, "antsCount", 0, 22).name("Ants count 2^").step(1)...
|
|
// new:
|
|
simFolder.add(Config, "antsStartCount", 0, 64).name("Starting ants").step(1)...
|
|
```
|
|
|
|
Add seedWorld checkbox to a new World section (or Simulation folder):
|
|
|
|
```typescript
|
|
simFolder
|
|
.add(Config, "seedWorld")
|
|
.name("Seed home + food")
|
|
.onChange(() => this.saveAndEmit("reset"));
|
|
```
|
|
|
|
**Step 5: Run tests and check**
|
|
|
|
Run: `bun test`
|
|
Expected: PASS
|
|
|
|
Run: `just check`
|
|
Expected: PASS
|
|
|
|
**Step 6: Commit**
|
|
|
|
Message: `Replace antsCount with antsStartCount and antBudget config`
|
|
|
|
---
|
|
|
|
### Task 1.2: Add ANTS_START_COUNT define to shaders
|
|
|
|
**Files:**
|
|
- Modify: `src/Renderer.ts`
|
|
|
|
The ant compute shader needs to know how many ants to initialize on the first frame vs leaving dormant. Add a define.
|
|
|
|
**Step 1: Add define to getCommonMaterialDefines()**
|
|
|
|
In `src/Renderer.ts` `getCommonMaterialDefines()`, add:
|
|
|
|
```typescript
|
|
ANTS_START_COUNT: String(Config.antsStartCount),
|
|
ANT_BUDGET: String(Config.antBudget),
|
|
```
|
|
|
|
**Step 2: Run `just check`**
|
|
|
|
Expected: PASS (defines are unused in shaders yet, just injected)
|
|
|
|
**Step 3: Commit**
|
|
|
|
Message: `Add ANTS_START_COUNT and ANT_BUDGET shader defines`
|
|
|
|
---
|
|
|
|
## Phase 2: World Init and Seeding
|
|
|
|
### Task 2.1: Configurable world seeding
|
|
|
|
**Files:**
|
|
- Modify: `src/WorldInit.ts`
|
|
- Test: `src/__tests__/worldInit.test.ts` (create)
|
|
|
|
**Step 1: Write failing tests**
|
|
|
|
```typescript
|
|
import { describe, expect, test } from "bun:test";
|
|
import { generateSideViewWorld } from "../WorldInit";
|
|
import { MAT_AIR, MAT_FOOD, MAT_HOME, MAT_SAND } from "../constants";
|
|
|
|
describe("generateSideViewWorld", () => {
|
|
const worldSize = 64;
|
|
|
|
test("bottom 60% is sand", () => {
|
|
const data = generateSideViewWorld(worldSize, true);
|
|
const sandHeight = Math.floor(worldSize * 0.6);
|
|
// check a cell in the middle of the sand region
|
|
const midY = Math.floor(sandHeight / 2);
|
|
const idx = (midY * worldSize + 10) * 4;
|
|
expect(data[idx]).toBe(MAT_SAND);
|
|
});
|
|
|
|
test("top 40% is air", () => {
|
|
const data = generateSideViewWorld(worldSize, true);
|
|
const sandHeight = Math.floor(worldSize * 0.6);
|
|
const airY = sandHeight + 5;
|
|
const idx = (airY * worldSize + 10) * 4;
|
|
expect(data[idx]).toBe(MAT_AIR);
|
|
});
|
|
|
|
test("seed=true places exactly one home and one food on surface", () => {
|
|
const data = generateSideViewWorld(worldSize, true);
|
|
let homeCount = 0;
|
|
let foodCount = 0;
|
|
for (let i = 0; i < data.length; i += 4) {
|
|
if (data[i] === MAT_HOME) homeCount++;
|
|
if (data[i] === MAT_FOOD) foodCount++;
|
|
}
|
|
expect(homeCount).toBe(1);
|
|
expect(foodCount).toBe(1);
|
|
});
|
|
|
|
test("seed=true places home and food on the surface row", () => {
|
|
const data = generateSideViewWorld(worldSize, true);
|
|
const sandHeight = Math.floor(worldSize * 0.6);
|
|
const surfaceRow = sandHeight - 1;
|
|
let homeY = -1;
|
|
let foodY = -1;
|
|
for (let y = 0; y < worldSize; y++) {
|
|
for (let x = 0; x < worldSize; x++) {
|
|
const idx = (y * worldSize + x) * 4;
|
|
if (data[idx] === MAT_HOME) homeY = y;
|
|
if (data[idx] === MAT_FOOD) foodY = y;
|
|
}
|
|
}
|
|
expect(homeY).toBe(surfaceRow);
|
|
expect(foodY).toBe(surfaceRow);
|
|
});
|
|
|
|
test("seed=false places no home or food", () => {
|
|
const data = generateSideViewWorld(worldSize, false);
|
|
let homeCount = 0;
|
|
let foodCount = 0;
|
|
for (let i = 0; i < data.length; i += 4) {
|
|
if (data[i] === MAT_HOME) homeCount++;
|
|
if (data[i] === MAT_FOOD) foodCount++;
|
|
}
|
|
expect(homeCount).toBe(0);
|
|
expect(foodCount).toBe(0);
|
|
});
|
|
|
|
test("seed=true places home and food at different x positions", () => {
|
|
const data = generateSideViewWorld(worldSize, true);
|
|
let homeX = -1;
|
|
let foodX = -1;
|
|
for (let y = 0; y < worldSize; y++) {
|
|
for (let x = 0; x < worldSize; x++) {
|
|
const idx = (y * worldSize + x) * 4;
|
|
if (data[idx] === MAT_HOME) homeX = x;
|
|
if (data[idx] === MAT_FOOD) foodX = x;
|
|
}
|
|
}
|
|
// they should be at different positions (random, but not same cell)
|
|
// note: could very rarely collide — use seeded random to avoid flakiness
|
|
expect(homeX).not.toBe(foodX);
|
|
});
|
|
});
|
|
```
|
|
|
|
Run: `bun test src/__tests__/worldInit.test.ts`
|
|
Expected: FAIL — generateSideViewWorld doesn't accept a seed parameter
|
|
|
|
**Step 2: Implement configurable seeding**
|
|
|
|
Update `src/WorldInit.ts`:
|
|
|
|
```typescript
|
|
import { MAT_FOOD, MAT_HOME, MAT_SAND } from "./constants";
|
|
|
|
// simple seeded PRNG for deterministic placement
|
|
function mulberry32(seed: number) {
|
|
return () => {
|
|
let t = (seed += 0x6d2b79f5);
|
|
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
};
|
|
}
|
|
|
|
export function generateSideViewWorld(
|
|
worldSize: number,
|
|
seed: boolean,
|
|
): Float32Array {
|
|
const data = new Float32Array(worldSize * worldSize * 4);
|
|
const sandHeight = Math.floor(worldSize * 0.6);
|
|
const surfaceRow = sandHeight - 1;
|
|
|
|
// fill bottom 60% with sand
|
|
for (let y = 0; y < sandHeight; y++) {
|
|
for (let x = 0; x < worldSize; x++) {
|
|
const idx = (y * worldSize + x) * 4;
|
|
data[idx] = MAT_SAND;
|
|
}
|
|
}
|
|
|
|
if (seed) {
|
|
const rng = mulberry32(Date.now());
|
|
// place home at random surface position (middle 60% of world width)
|
|
const margin = Math.floor(worldSize * 0.2);
|
|
const homeX =
|
|
margin + Math.floor(rng() * (worldSize - 2 * margin));
|
|
|
|
const homeIdx = (surfaceRow * worldSize + homeX) * 4;
|
|
data[homeIdx] = MAT_HOME;
|
|
|
|
// place food at a different random surface position
|
|
let foodX = homeX;
|
|
while (foodX === homeX) {
|
|
foodX =
|
|
margin + Math.floor(rng() * (worldSize - 2 * margin));
|
|
}
|
|
const foodIdx = (surfaceRow * worldSize + foodX) * 4;
|
|
data[foodIdx] = MAT_FOOD;
|
|
}
|
|
|
|
return data;
|
|
}
|
|
```
|
|
|
|
**Step 3: Update Renderer.ts to pass seedWorld**
|
|
|
|
In `src/Renderer.ts` `reset()` method, change:
|
|
|
|
```typescript
|
|
// old:
|
|
const data = generateSideViewWorld(Config.worldSize);
|
|
// new:
|
|
const data = generateSideViewWorld(Config.worldSize, Config.seedWorld);
|
|
```
|
|
|
|
**Step 4: Run tests**
|
|
|
|
Run: `bun test`
|
|
Expected: PASS
|
|
|
|
Run: `just check`
|
|
Expected: PASS
|
|
|
|
**Step 5: Commit**
|
|
|
|
Message: `Add configurable world seeding with random home and food placement`
|
|
|
|
---
|
|
|
|
## Phase 3: Ant Physics
|
|
|
|
The core bug fix. Gravity, surface constraint, collision displacement.
|
|
|
|
### Task 3.1: Ant gravity — fall through air
|
|
|
|
**Files:**
|
|
- Modify: `src/shaders/antsCompute.frag`
|
|
|
|
**Step 1: Add gravity logic at the top of the movement section**
|
|
|
|
After the init block (after `if (pos == vec2(0)) { ... }`) and before the existing movement logic, add a gravity/surface check. This replaces the flat movement model with physics-aware movement.
|
|
|
|
The new structure of `main()` after init becomes:
|
|
|
|
```glsl
|
|
// --- GRAVITY AND SURFACE CHECK ---
|
|
// check if ant has any solid neighbor (cardinal directions)
|
|
vec2 cellCenter = roundUvToCellCenter(pos);
|
|
float belowMat = texture(tWorld, cellCenter - vec2(0., cellSize)).x;
|
|
float aboveMat = texture(tWorld, cellCenter + vec2(0., cellSize)).x;
|
|
float leftMat = texture(tWorld, cellCenter - vec2(cellSize, 0.)).x;
|
|
float rightMat = texture(tWorld, cellCenter + vec2(cellSize, 0.)).x;
|
|
float currentMat = texture(tWorld, cellCenter).x;
|
|
|
|
bool belowSolid = (int(belowMat) != MAT_AIR);
|
|
bool aboveSolid = (int(aboveMat) != MAT_AIR);
|
|
bool leftSolid = (int(leftMat) != MAT_AIR);
|
|
bool rightSolid = (int(rightMat) != MAT_AIR);
|
|
bool onSurface = belowSolid || aboveSolid || leftSolid || rightSolid;
|
|
|
|
// collision displacement: if current cell is now solid (sand fell on us), push to nearest air
|
|
if (int(currentMat) != MAT_AIR && int(currentMat) != MAT_HOME) {
|
|
// try up first, then left, then right
|
|
vec2 upPos = cellCenter + vec2(0., cellSize);
|
|
vec2 leftPos = cellCenter - vec2(cellSize, 0.);
|
|
vec2 rightPos = cellCenter + vec2(cellSize, 0.);
|
|
if (int(texture(tWorld, upPos).x) == MAT_AIR) {
|
|
pos = upPos;
|
|
} else if (int(texture(tWorld, leftPos).x) == MAT_AIR) {
|
|
pos = leftPos;
|
|
} else if (int(texture(tWorld, rightPos).x) == MAT_AIR) {
|
|
pos = rightPos;
|
|
}
|
|
// recalculate surface after displacement
|
|
cellCenter = roundUvToCellCenter(pos);
|
|
belowMat = texture(tWorld, cellCenter - vec2(0., cellSize)).x;
|
|
belowSolid = (int(belowMat) != MAT_AIR);
|
|
onSurface = belowSolid; // simplified re-check
|
|
}
|
|
|
|
bool isFalling = false;
|
|
|
|
// GRAVITY: if nothing solid below and not at world bottom, fall
|
|
if (!belowSolid && pos.y > cellSize) {
|
|
pos.y -= cellSize;
|
|
isFalling = true;
|
|
}
|
|
```
|
|
|
|
**Step 2: Gate all steering logic behind `!isFalling`**
|
|
|
|
Wrap the entire existing movement block (food seeking, pheromone following, digging bias, wandering) in:
|
|
|
|
```glsl
|
|
if (!isFalling) {
|
|
// ... all existing movement code from lines 126-238 ...
|
|
}
|
|
```
|
|
|
|
**Step 3: Update isObstacle() to block ALL non-air materials**
|
|
|
|
Change `isObstacle()` to treat all solid materials as obstacles (not just rock):
|
|
|
|
```glsl
|
|
bool isObstacle(vec2 pos) {
|
|
float materialId = texture(tWorld, roundUvToCellCenter(pos)).x;
|
|
int matInt = int(materialId);
|
|
// ants can't walk into any solid material (only through air and home)
|
|
return matInt != MAT_AIR && matInt != MAT_HOME;
|
|
}
|
|
```
|
|
|
|
This prevents ants from walking through sand, dirt, food, or rock. They can only enter air and home cells.
|
|
|
|
**Step 4: Run `just check`**
|
|
|
|
Expected: PASS
|
|
|
|
**Step 5: Commit**
|
|
|
|
Message: `Add ant gravity, surface constraint, and collision displacement`
|
|
|
|
---
|
|
|
|
### Task 3.2: Ant activation — budget pool model
|
|
|
|
**Files:**
|
|
- Modify: `src/shaders/antsCompute.frag`
|
|
|
|
Currently all ants at `pos == vec2(0)` get initialized. We need only the first `ANTS_START_COUNT` ants to activate. The rest stay dormant at (0,0).
|
|
|
|
**Step 1: Gate initialization by ant index**
|
|
|
|
Replace the init block:
|
|
|
|
```glsl
|
|
// calculate this ant's index from its UV position in the texture
|
|
ivec2 texSize = textureSize(tLastState, 0);
|
|
int antIndex = int(vUv.y * float(texSize.y)) * texSize.x + int(vUv.x * float(texSize.x));
|
|
|
|
if (pos == vec2(0)) {
|
|
// only activate ants within the start count (or activated by spawn buffer later)
|
|
if (antIndex >= ANTS_START_COUNT) {
|
|
// dormant ant — output zeros and skip everything
|
|
FragColor = vec4(0);
|
|
FragColorExt = vec4(0);
|
|
return;
|
|
}
|
|
|
|
#if VIEW_MODE_SIDE
|
|
// spawn on sand surface (existing logic)
|
|
float spawnX = rand(vUv * 10000.);
|
|
float pixelSize = 1.0 / float(texSize.x);
|
|
float surfaceY = 0.6;
|
|
for (float scanY = 1.0; scanY > 0.0; scanY -= pixelSize) {
|
|
float matId = texture(tWorld, vec2(spawnX, scanY)).x;
|
|
if (matId > 0.5) {
|
|
surfaceY = scanY + pixelSize;
|
|
break;
|
|
}
|
|
}
|
|
pos = vec2(spawnX, surfaceY);
|
|
angle = -PI * 0.5;
|
|
#else
|
|
pos = vec2(0.5);
|
|
angle = rand(vUv * 10000.) * 2. * PI;
|
|
#endif
|
|
isCarrying = 0.;
|
|
storage = 0.;
|
|
personality = rand(vUv * 42069.);
|
|
cargoMaterialId = 0.;
|
|
pathIntDx = 0.;
|
|
pathIntDy = 0.;
|
|
}
|
|
```
|
|
|
|
Note: the `antIndex` variable will be reused in a later task for the spawn buffer. The `pixelSize` variable changes to use `texSize` from the world texture — need to keep the scan using world texture size, not ant texture size:
|
|
|
|
```glsl
|
|
float pixelSize = 1.0 / float(textureSize(tWorld, 0).x);
|
|
```
|
|
|
|
**Step 2: Run `just check`**
|
|
|
|
Expected: PASS
|
|
|
|
**Step 3: Commit**
|
|
|
|
Message: `Gate ant initialization by ANTS_START_COUNT budget`
|
|
|
|
---
|
|
|
|
## Phase 4: Ant Brain — Priority Stack
|
|
|
|
### Task 4.1: Rewrite ant movement as priority stack
|
|
|
|
**Files:**
|
|
- Modify: `src/shaders/antsCompute.frag`
|
|
|
|
This is the biggest single change. Replace the existing movement logic (lines 126-238 in the current shader) with the priority-based behavioral model.
|
|
|
|
**Step 1: Rewrite the movement section**
|
|
|
|
The new movement block (inside the `if (!isFalling) { ... }` gate from Task 3.1) becomes:
|
|
|
|
```glsl
|
|
if (!isFalling) {
|
|
bool acted = false;
|
|
|
|
// ---- PRIORITY 1: DEPOSIT ----
|
|
if (!acted && isCarrying == 1.) {
|
|
float cellMatId = texture(tWorld, roundUvToCellCenter(pos)).x;
|
|
|
|
// carrying food and at home -> drop food
|
|
if (int(cargoMaterialId) == MAT_FOOD && tryDropFood(pos)) {
|
|
isCarrying = 0.;
|
|
cargoMaterialId = 0.;
|
|
angle += PI;
|
|
storage = getMaxScentStorage(vUv);
|
|
acted = true;
|
|
}
|
|
|
|
// carrying powder and on surface (air above, solid below) -> deposit
|
|
if (!acted && int(cargoMaterialId) != MAT_FOOD) {
|
|
if (int(cellMatId) == MAT_AIR) {
|
|
vec2 belowPos = pos - vec2(0., cellSize);
|
|
float belowMatId = texture(tWorld, roundUvToCellCenter(belowPos)).x;
|
|
vec2 abovePos = pos + vec2(0., cellSize);
|
|
float aboveMatId = texture(tWorld, roundUvToCellCenter(abovePos)).x;
|
|
if ((int(belowMatId) != MAT_AIR || belowPos.y <= 0.)
|
|
&& int(aboveMatId) == MAT_AIR) {
|
|
isCarrying = 0.;
|
|
// keep cargoMaterialId set so discretize reads it this frame
|
|
angle += PI;
|
|
storage = getMaxScentStorage(vUv);
|
|
acted = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- PRIORITY 2: NAVIGATE (carrying) ----
|
|
if (!acted && isCarrying == 1.) {
|
|
if (int(cargoMaterialId) == MAT_FOOD) {
|
|
// follow toHome pheromone
|
|
float sAhead = smell(applyOffsetToPos(pos, vec2(cos(angle), sin(angle)) * sampleDistance), 1.);
|
|
float sLeft = smell(applyOffsetToPos(pos, vec2(cos(angle - ANT_ROTATION_ANGLE), sin(angle - ANT_ROTATION_ANGLE)) * sampleDistance), 1.);
|
|
float sRight = smell(applyOffsetToPos(pos, vec2(cos(angle + ANT_ROTATION_ANGLE), sin(angle + ANT_ROTATION_ANGLE)) * sampleDistance), 1.);
|
|
|
|
if (sLeft > sAhead && sLeft > sRight) {
|
|
angle -= ANT_ROTATION_ANGLE;
|
|
} else if (sRight > sAhead && sRight > sLeft) {
|
|
angle += ANT_ROTATION_ANGLE;
|
|
}
|
|
} else {
|
|
// carrying powder: bias upward toward surface
|
|
#if VIEW_MODE_SIDE
|
|
float upwardBias = PI * 0.5;
|
|
float angleDiff = upwardBias - angle;
|
|
angleDiff = mod(angleDiff + PI, 2.0 * PI) - PI;
|
|
angle += angleDiff * 0.3;
|
|
#endif
|
|
}
|
|
// add wander noise to carrying ants too
|
|
float noise2 = rand(vUv * 1000. + fract(uTime / 1000.) + 0.2);
|
|
if (noise2 > 0.5) {
|
|
angle += ANT_ROTATION_ANGLE;
|
|
} else {
|
|
angle -= ANT_ROTATION_ANGLE;
|
|
}
|
|
acted = true;
|
|
}
|
|
|
|
// ---- PRIORITY 3: FORAGE (not carrying, food scent detected) ----
|
|
if (!acted && isCarrying == 0.) {
|
|
float sAhead = smell(applyOffsetToPos(pos, vec2(cos(angle), sin(angle)) * sampleDistance), 0.);
|
|
float sLeft = smell(applyOffsetToPos(pos, vec2(cos(angle - ANT_ROTATION_ANGLE), sin(angle - ANT_ROTATION_ANGLE)) * sampleDistance), 0.);
|
|
float sRight = smell(applyOffsetToPos(pos, vec2(cos(angle + ANT_ROTATION_ANGLE), sin(angle + ANT_ROTATION_ANGLE)) * sampleDistance), 0.);
|
|
|
|
float maxSmell = max(sAhead, max(sLeft, sRight));
|
|
|
|
if (maxSmell > SCENT_THRESHOLD) {
|
|
// follow food scent
|
|
if (sLeft > sAhead && sLeft > sRight) {
|
|
angle -= ANT_ROTATION_ANGLE;
|
|
} else if (sRight > sAhead && sRight > sLeft) {
|
|
angle += ANT_ROTATION_ANGLE;
|
|
}
|
|
acted = true;
|
|
}
|
|
}
|
|
|
|
// ---- PRIORITY 4: DIG (not carrying, diggable material nearby below surface) ----
|
|
#if VIEW_MODE_SIDE
|
|
if (!acted && isCarrying == 0.) {
|
|
// check cell below for diggable material
|
|
vec2 belowUv = roundUvToCellCenter(pos - vec2(0., cellSize));
|
|
float belowMat2 = texture(tWorld, belowUv).x;
|
|
vec4 belowProps2 = texelFetch(uMaterialProps, ivec2(int(belowMat2), 0), 0);
|
|
|
|
// suppress digging if on the surface (air above) — don't dig topsoil into sky
|
|
vec2 aboveUv = roundUvToCellCenter(pos + vec2(0., cellSize));
|
|
float aboveMat2 = texture(tWorld, aboveUv).x;
|
|
bool onSurfaceTop = (int(aboveMat2) == MAT_AIR);
|
|
|
|
if (!onSurfaceTop
|
|
&& belowProps2.r == BEHAVIOR_POWDER
|
|
&& belowProps2.b <= ANT_CARRY_STRENGTH) {
|
|
// bias angle toward ~40 degrees below horizontal (angle of repose)
|
|
// pick left-down or right-down based on current facing
|
|
float targetAngle = (cos(angle) >= 0.)
|
|
? -0.7 // ~-40 degrees (right and down)
|
|
: -(PI - 0.7); // ~-(180-40) degrees (left and down)
|
|
float angleDiff2 = targetAngle - angle;
|
|
angleDiff2 = mod(angleDiff2 + PI, 2.0 * PI) - PI;
|
|
angle += angleDiff2 * 0.2;
|
|
acted = true;
|
|
}
|
|
}
|
|
#endif
|
|
|
|
// ---- PRIORITY 5: WANDER (fallback) ----
|
|
if (!acted) {
|
|
// random walk with noise
|
|
if (noise < 0.33) {
|
|
angle += ANT_ROTATION_ANGLE;
|
|
} else if (noise < 0.66) {
|
|
angle -= ANT_ROTATION_ANGLE;
|
|
}
|
|
float noise2 = rand(vUv * 1000. + fract(uTime / 1000.) + 0.2);
|
|
if (noise2 > 0.5) {
|
|
angle += ANT_ROTATION_ANGLE * 2.;
|
|
} else {
|
|
angle -= ANT_ROTATION_ANGLE * 2.;
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Keep the pickup logic after movement**
|
|
|
|
The existing pickup block (lines 247-268 in current shader) stays mostly the same, after the `pos = applyOffsetToPos(pos, offset)` line. The deposit-at-home block (lines 271-279) is now handled in Priority 1, so **remove** the duplicate at lines 271-294 (both the home drop and the powder deposit sections — they're now in Priority 1).
|
|
|
|
The movement application and wall-bounce remain:
|
|
|
|
```glsl
|
|
if (!isFalling) {
|
|
vec2 offset = vec2(cos(angle), sin(angle));
|
|
pos = applyOffsetToPos(pos, offset);
|
|
|
|
if (fract(pos.x) == 0. || fract(pos.y) == 0. || (!wasObstacle && isObstacle(pos + offset * cellSize))) {
|
|
angle += PI * (noise - 0.5);
|
|
}
|
|
|
|
// pickup logic (food and diggable powder)
|
|
if (isCarrying == 0.) {
|
|
float cellMatId = texture(tWorld, roundUvToCellCenter(pos)).x;
|
|
int cellMatInt = int(cellMatId);
|
|
|
|
if (cellMatInt == MAT_FOOD) {
|
|
isCarrying = 1.;
|
|
cargoMaterialId = cellMatId;
|
|
angle += PI;
|
|
storage = getMaxScentStorage(vUv);
|
|
} else if (cellMatInt != MAT_AIR && cellMatInt != MAT_HOME) {
|
|
vec4 props = texelFetch(uMaterialProps, ivec2(cellMatInt, 0), 0);
|
|
float behavior = props.r;
|
|
float hardness = props.b;
|
|
if (behavior == BEHAVIOR_POWDER && hardness <= ANT_CARRY_STRENGTH) {
|
|
isCarrying = 1.;
|
|
cargoMaterialId = cellMatId;
|
|
angle += PI;
|
|
storage = getMaxScentStorage(vUv);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 3: Run `just check`**
|
|
|
|
Expected: PASS
|
|
|
|
**Step 4: Commit**
|
|
|
|
Message: `Rewrite ant behavior as priority stack with suppressors`
|
|
|
|
---
|
|
|
|
## Phase 5: Unified Brush — Ants as Droppable Element
|
|
|
|
### Task 5.1: Add ant spawn buffer texture
|
|
|
|
**Files:**
|
|
- Modify: `src/Renderer.ts`
|
|
|
|
We need a small texture that the draw pass writes ant spawn positions to, and the ant compute pass reads from. A 1x1 DataTexture is enough — it stores the spawn position and count for this frame.
|
|
|
|
Actually, a simpler approach: use a uniform vec4 on AntsComputeScene that encodes `(spawnX, spawnY, spawnCount, 0)`. The JS side sets this each frame based on pointer state + brush element selection.
|
|
|
|
**Step 1: Add spawn uniform to AntsComputeScene**
|
|
|
|
In `src/scenes/AntsComputeScene.ts`, add to the uniforms object:
|
|
|
|
```typescript
|
|
uAntSpawn: { value: new THREE.Vector4(0, 0, 0, 0) },
|
|
```
|
|
|
|
**Step 2: Add ant spawn uniform in Renderer.renderSimulation()**
|
|
|
|
In `src/Renderer.ts` `renderSimulation()`, before the ant compute render call, add:
|
|
|
|
```typescript
|
|
scenes.ants.material.uniforms.uAntSpawn.value =
|
|
scenes.screen.antSpawnRequest;
|
|
// clear after reading
|
|
scenes.screen.antSpawnRequest.set(0, 0, 0, 0);
|
|
```
|
|
|
|
**Step 3: Run `just check`**
|
|
|
|
Expected: PASS
|
|
|
|
**Step 4: Commit**
|
|
|
|
Message: `Add ant spawn uniform to AntsComputeScene`
|
|
|
|
---
|
|
|
|
### Task 5.2: Handle ant brush element in ScreenScene
|
|
|
|
**Files:**
|
|
- Modify: `src/scenes/ScreenScene.ts`
|
|
- Modify: `src/Config.ts`
|
|
|
|
**Step 1: Add antSpawnRequest vector and keybinding**
|
|
|
|
In `src/scenes/ScreenScene.ts`:
|
|
|
|
Add a public field:
|
|
|
|
```typescript
|
|
public readonly antSpawnRequest: THREE.Vector4 = new THREE.Vector4(0, 0, 0, 0);
|
|
```
|
|
|
|
Add a constant for the "ants" brush element. Since material IDs are 0-255, use a sentinel value like `999` to represent "draw ants":
|
|
|
|
```typescript
|
|
const BRUSH_ANTS = 999;
|
|
```
|
|
|
|
Add keybinding in the `keydown` handler:
|
|
|
|
```typescript
|
|
case "KeyA": {
|
|
this.drawMode = BRUSH_ANTS;
|
|
break;
|
|
}
|
|
```
|
|
|
|
Export `BRUSH_ANTS` so GUI can reference it.
|
|
|
|
**Step 2: Modify effectiveDrawMode to handle ants**
|
|
|
|
When the effective draw mode is `BRUSH_ANTS`, don't pass it to the draw shader (which writes materials). Instead, populate `antSpawnRequest` with the pointer position and brush radius.
|
|
|
|
Add to the pointer move/down handler (or a per-frame update):
|
|
|
|
In the `update()` method (currently empty), or in the render loop — when `effectiveDrawMode === BRUSH_ANTS` and pointer is down:
|
|
|
|
```typescript
|
|
public update() {
|
|
if (this.effectiveDrawMode === BRUSH_ANTS && this.isPointerDown) {
|
|
this.antSpawnRequest.set(
|
|
this.pointerPosition.x,
|
|
this.pointerPosition.y,
|
|
Config.brushRadius,
|
|
1, // flag: spawn requested
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
Override `effectiveDrawMode` to return -1 (no material drawing) when the ant brush is active:
|
|
|
|
```typescript
|
|
public get effectiveDrawMode(): number {
|
|
if (this.drawMode === BRUSH_ANTS) return -1;
|
|
if (this.drawMode >= 0) return this.drawMode;
|
|
if (this.isPointerDown && Config.brushMaterial >= 0) {
|
|
if (Config.brushMaterial === BRUSH_ANTS) return -1;
|
|
return Config.brushMaterial;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
public get isAntBrushActive(): boolean {
|
|
if (this.drawMode === BRUSH_ANTS) return true;
|
|
if (this.isPointerDown && Config.brushMaterial === BRUSH_ANTS) return true;
|
|
return false;
|
|
}
|
|
```
|
|
|
|
**Step 3: Update GUI to include ants in brush palette**
|
|
|
|
In `src/GUI.ts`, add to `brushLabels`:
|
|
|
|
```typescript
|
|
"ants (A)": 999,
|
|
```
|
|
|
|
**Step 4: Run `just check`**
|
|
|
|
Expected: PASS
|
|
|
|
**Step 5: Commit**
|
|
|
|
Message: `Add ant brush element with keybinding and GUI entry`
|
|
|
|
---
|
|
|
|
### Task 5.3: Activate dormant ants from spawn buffer in shader
|
|
|
|
**Files:**
|
|
- Modify: `src/shaders/antsCompute.frag`
|
|
|
|
**Step 1: Add spawn activation logic**
|
|
|
|
Add `uniform vec4 uAntSpawn;` declaration near the other uniforms.
|
|
|
|
In the init block, after the dormant check (`if (antIndex >= ANTS_START_COUNT)`), add spawn buffer activation:
|
|
|
|
```glsl
|
|
if (pos == vec2(0)) {
|
|
// check if this ant should activate via spawn request
|
|
bool spawnRequested = (uAntSpawn.w > 0.5);
|
|
|
|
if (antIndex >= ANTS_START_COUNT && !spawnRequested) {
|
|
// dormant ant, no spawn request — skip
|
|
FragColor = vec4(0);
|
|
FragColorExt = vec4(0);
|
|
return;
|
|
}
|
|
|
|
if (antIndex >= ANTS_START_COUNT && spawnRequested) {
|
|
// activate this ant at spawn position with some scatter
|
|
float scatter = uAntSpawn.z / WORLD_SIZE; // brush radius in UV space
|
|
float rngX = rand(vUv * 10000. + fract(uTime / 1000.));
|
|
float rngY = rand(vUv * 20000. + fract(uTime / 1000.) + 0.5);
|
|
pos = vec2(
|
|
uAntSpawn.x + (rngX - 0.5) * scatter,
|
|
uAntSpawn.y + (rngY - 0.5) * scatter
|
|
);
|
|
pos = clamp(pos, 0., 1.);
|
|
angle = rand(vUv * 42069.) * 2.0 * PI;
|
|
isCarrying = 0.;
|
|
storage = 0.;
|
|
personality = rand(vUv * 42069.);
|
|
cargoMaterialId = 0.;
|
|
pathIntDx = 0.;
|
|
pathIntDy = 0.;
|
|
} else {
|
|
// normal init for starting ants (existing logic)
|
|
#if VIEW_MODE_SIDE
|
|
float spawnX = rand(vUv * 10000.);
|
|
float pixelSize = 1.0 / float(textureSize(tWorld, 0).x);
|
|
float surfaceY = 0.6;
|
|
for (float scanY = 1.0; scanY > 0.0; scanY -= pixelSize) {
|
|
float matId = texture(tWorld, vec2(spawnX, scanY)).x;
|
|
if (matId > 0.5) {
|
|
surfaceY = scanY + pixelSize;
|
|
break;
|
|
}
|
|
}
|
|
pos = vec2(spawnX, surfaceY);
|
|
angle = -PI * 0.5;
|
|
#else
|
|
pos = vec2(0.5);
|
|
angle = rand(vUv * 10000.) * 2. * PI;
|
|
#endif
|
|
isCarrying = 0.;
|
|
storage = 0.;
|
|
personality = rand(vUv * 42069.);
|
|
cargoMaterialId = 0.;
|
|
pathIntDx = 0.;
|
|
pathIntDy = 0.;
|
|
}
|
|
}
|
|
```
|
|
|
|
Note: this activates ALL dormant ants when a spawn request comes in. To limit activation to `brushRadius` count of ants per frame, we'd need a counter. For simplicity, we limit by checking if `antIndex` is within a small range of the "next available" slot. A simpler approach: only activate one ant per frame per spawn request, using the frame counter as a slot selector:
|
|
|
|
Actually, the simplest correct approach: each dormant ant uses a random check against `brushRadius / antBudget` probability to decide if it activates this frame. This naturally gives ~brushRadius activations per frame:
|
|
|
|
```glsl
|
|
if (antIndex >= ANTS_START_COUNT && spawnRequested) {
|
|
// probabilistic activation: ~brushRadius ants per frame
|
|
float activationChance = uAntSpawn.z / float(ANT_BUDGET);
|
|
float roll = rand(vUv * 50000. + fract(uTime / 1000.));
|
|
if (roll > activationChance) {
|
|
// not selected this frame — stay dormant
|
|
FragColor = vec4(0);
|
|
FragColorExt = vec4(0);
|
|
return;
|
|
}
|
|
// ... activate at spawn position ...
|
|
}
|
|
```
|
|
|
|
**Step 2: Run `just check`**
|
|
|
|
Expected: PASS
|
|
|
|
**Step 3: Commit**
|
|
|
|
Message: `Activate dormant ants from spawn buffer in shader`
|
|
|
|
---
|
|
|
|
## Phase 6: Sand Color Variation
|
|
|
|
### Task 6.1: Add per-pixel hash noise to material rendering
|
|
|
|
**Files:**
|
|
- Modify: `src/shaders/screenWorld.frag`
|
|
|
|
**Step 1: Add hash function and noise to material color**
|
|
|
|
Add a spatial hash function and apply it to material colors:
|
|
|
|
```glsl
|
|
// add before main():
|
|
float hash(ivec2 p) {
|
|
int h = p.x * 374761393 + p.y * 668265263;
|
|
h = (h ^ (h >> 13)) * 1274126177;
|
|
return float(h & 0x7fffffff) / float(0x7fffffff);
|
|
}
|
|
```
|
|
|
|
In `main()`, modify the material color section:
|
|
|
|
```glsl
|
|
// old:
|
|
if (materialId != MAT_AIR) {
|
|
vec4 matColor = texelFetch(uMaterialColors, ivec2(materialId, 0), 0);
|
|
color = mix(matColor.rgb, t, a * 0.3);
|
|
}
|
|
|
|
// new:
|
|
if (materialId != MAT_AIR) {
|
|
vec4 matColor = texelFetch(uMaterialColors, ivec2(materialId, 0), 0);
|
|
// per-pixel color variation: +/-4% brightness noise
|
|
float colorNoise = hash(ivec2(gl_FragCoord.xy)) * 0.08 - 0.04;
|
|
vec3 variedColor = matColor.rgb + colorNoise;
|
|
color = mix(variedColor, t, a * 0.3);
|
|
}
|
|
```
|
|
|
|
**Step 2: Run `just check`**
|
|
|
|
Expected: PASS
|
|
|
|
**Step 3: Commit**
|
|
|
|
Message: `Add per-pixel hash noise to material colors`
|
|
|
|
---
|
|
|
|
## Phase 7: Cleanup and Polish
|
|
|
|
### Task 7.1: Remove old brushMaterial config references
|
|
|
|
**Files:**
|
|
- Modify: `src/Config.ts`
|
|
|
|
If `brushMaterial` was kept for backward compatibility, verify all references now use `brushElement` / the palette labels. If `brushMaterial` is still the backing store for the GUI dropdown (which it is — lil-gui writes to `Config.brushMaterial` via the `onChange`), then keep it but rename it mentally. Actually, the existing `brushMaterial` config key already stores a material ID (or -1 or 999 for ants). It works as-is. No change needed here — skip this task.
|
|
|
|
---
|
|
|
|
### Task 7.2: Update stats overlay for new ant count
|
|
|
|
**Files:**
|
|
- Modify: `src/StatsOverlay.ts`
|
|
|
|
The stats overlay shows "ants: 4096". With the budget model, it should show active ants vs budget. The `ColonyStats` CPU readback already counts ants — it reads the ant texture and counts non-zero positions.
|
|
|
|
**Step 1: Check ColonyStats**
|
|
|
|
Read `src/ColonyStats.ts` to see what it already reports. If it counts active ants, just make sure the overlay displays it correctly. If not, add a count of ants where `pos != vec2(0)`.
|
|
|
|
**Step 2: Update overlay display**
|
|
|
|
In `src/StatsOverlay.ts`, change the ants display line to show active/budget:
|
|
|
|
```typescript
|
|
// e.g., "ants 12 / 512"
|
|
```
|
|
|
|
**Step 3: Run `just check`**
|
|
|
|
Expected: PASS
|
|
|
|
**Step 4: Commit**
|
|
|
|
Message: `Update stats overlay to show active ant count vs budget`
|
|
|
|
---
|
|
|
|
### Task 7.3: Update CLAUDE.md
|
|
|
|
**Files:**
|
|
- Modify: `CLAUDE.md`
|
|
|
|
Update the architecture docs to reflect:
|
|
- New config keys (antsStartCount, antBudget, seedWorld)
|
|
- Ant physics model (gravity, surface constraint)
|
|
- Priority stack behavioral model
|
|
- Unified brush with ant spawning
|
|
- Sand color variation
|
|
|
|
**Step 1: Update relevant sections of CLAUDE.md**
|
|
|
|
**Step 2: Commit**
|
|
|
|
Message: `Update CLAUDE.md for ant behavior overhaul`
|
|
|
|
---
|
|
|
|
## Future Phases (not implemented now, hooks are in place)
|
|
|
|
### Phase B: Digging Pheromone
|
|
|
|
- Add a 4th pheromone channel or repurpose repellent (A channel) for "dig here" scent
|
|
- Ants deposit dig pheromone when picking up material
|
|
- Other ants attracted to dig pheromone → dig nearby
|
|
- Pheromone decay rate controls tunnel compactness
|
|
- Hook: the `antsPresenceRenderTarget` (already cleared each frame) can be populated with ant density per cell, enabling the chamber→tunnel density threshold transition
|
|
|
|
### Phase C: Colony Dynamics
|
|
|
|
- Age parameter per ant (repurpose or extend `personality` in texture 1)
|
|
- Young ants: higher dig probability, slanted tunnel preference
|
|
- Old ants: higher forage probability, vertical digging
|
|
- Colony growth: activate new ants from budget when delivered-food count exceeds threshold
|
|
- Expansion trigger: population / excavated-area ratio threshold
|
|
- Emergency mode: all ants dig when nest area drops below expected
|
|
- Queen mechanics: founding behavior, reproduction gating
|