Files
OpenClaw-bot-review/lib/pixel-office/bugs/bugSystem.ts
T

1028 lines
34 KiB
TypeScript

import { TILE_SIZE } from '../types'
import {
BUG_ABDOMEN_LENGTH,
BUG_ABDOMEN_WIDTH,
BUG_BORDER_PADDING,
BUG_DEFAULT_COUNT,
BUG_GAIT_SWITCH_SEC,
BUG_HEAD_SIZE,
BUG_LEG_UPDATE_INTERVAL_SEC,
BUG_LEG_SEGMENT_1,
BUG_LEG_SEGMENT_2,
BUG_MAX_COUNT,
BUG_MAX_SPEED,
BUG_MIN_SPEED,
BUG_MORANDI_COLORS,
BUG_OUT_OF_BOUNDS_MARGIN,
BUG_PERCEPTION_RADIUS,
BUG_SEPARATION_RADIUS,
BUG_GRID_CELL_SIZE,
BUG_SEPARATION_WEIGHT,
BUG_ALIGNMENT_WEIGHT,
BUG_COHESION_WEIGHT,
BUG_EDGE_ATTRACTION_WEIGHT,
BUG_PHEROMONE_CELL_SIZE,
BUG_PHEROMONE_DEPOSIT_PER_SEC,
BUG_PHEROMONE_EVAPORATION_PER_SEC,
BUG_PHEROMONE_MAX_STRENGTH,
BUG_PHEROMONE_MIN_DETECT,
BUG_PHEROMONE_FOLLOW_WEIGHT,
BUG_TRAIL_FOLLOW_RADIUS,
BUG_TRAIL_FOLLOW_PROB,
BUG_TRAIL_BREAK_PROB,
BUG_TRAIL_STEERING_WEIGHT,
BUG_CURSOR_REPULSION_RADIUS,
BUG_CURSOR_REPULSION_STRENGTH,
BUG_STEP_DISTANCE,
BUG_STEP_DURATION_SEC,
BUG_STEP_TRIGGER,
BUG_THORAX_LENGTH,
BUG_THORAX_WIDTH,
BUG_WANDER_MAX_SEC,
BUG_WANDER_MIN_SEC,
} from './config'
import type { BugBehaviorType, BugEntity, BugLeg } from './types'
import { BugSpatialGrid } from './spatialGrid'
import { BugPheromoneField } from './pheromoneField'
function randomRange(min: number, max: number): number {
return min + Math.random() * (max - min)
}
function clamp(v: number, min: number, max: number): number {
if (v < min) return min
if (v > max) return max
return v
}
function normalizeRadians(v: number): number {
let x = v
while (x > Math.PI) x -= Math.PI * 2
while (x < -Math.PI) x += Math.PI * 2
return x
}
function pickBehavior(): BugBehaviorType {
const r = Math.random()
if (r < 0.6) return 'social'
if (r < 0.9) return 'edgeDweller'
return 'loner'
}
function pickSizeScale(): number {
const r = Math.random()
if (r < 0.2) return 0.7
if (r < 0.7) return 0.85
return 1.0
}
function localToWorld(
bugX: number,
bugY: number,
heading: number,
lx: number,
ly: number,
): { x: number; y: number } {
const cos = Math.cos(heading)
const sin = Math.sin(heading)
return {
x: bugX + lx * cos - ly * sin,
y: bugY + lx * sin + ly * cos,
}
}
function solveTwoBoneIK(
ax: number,
ay: number,
fx: number,
fy: number,
l1: number,
l2: number,
isRightSide: boolean,
): { x: number; y: number } {
const dx = fx - ax
const dy = fy - ay
let c = Math.hypot(dx, dy)
if (c > l1 + l2) c = l1 + l2
c = Math.max(c, 0.0001)
const toTargetAngle = Math.atan2(dy, dx)
let cosAngle = (l1 * l1 + c * c - l2 * l2) / (2 * l1 * c)
cosAngle = clamp(cosAngle, -1, 1)
const angle = Math.acos(cosAngle)
const kneeAngle = isRightSide ? (toTargetAngle - angle) : (toTargetAngle + angle)
return {
x: ax + Math.cos(kneeAngle) * l1,
y: ay + Math.sin(kneeAngle) * l1,
}
}
const LEG_SPECS: Array<{ anchorX: number; anchorY: number; restX: number; restY: number; side: -1 | 1; group: 0 | 1 }> = [
{ anchorX: 1.1, anchorY: -0.78, restX: 4.7, restY: -5.9, side: -1, group: 0 }, // left front
{ anchorX: 0.0, anchorY: -0.78, restX: 0.0, restY: -6.9, side: -1, group: 1 }, // left mid
{ anchorX: -1.1, anchorY: -0.78, restX: -4.7, restY: -5.9, side: -1, group: 0 }, // left back
{ anchorX: 1.1, anchorY: 0.78, restX: 4.7, restY: 5.9, side: 1, group: 1 }, // right front
{ anchorX: 0.0, anchorY: 0.78, restX: 0.0, restY: 6.9, side: 1, group: 0 }, // right mid
{ anchorX: -1.1, anchorY: 0.78, restX: -4.7, restY: 5.9, side: 1, group: 1 }, // right back
]
const LOGO_CARRY_FIXED_COUNT = 6
export class BugSystem {
private bugs: BugEntity[] = []
private enabled = false
private targetCount = BUG_DEFAULT_COUNT
private nextId = 1
private grid: BugSpatialGrid
private pheromones: BugPheromoneField
private cursorActive = false
private cursorX = 0
private cursorY = 0
private logoCarry: {
active: boolean
startX: number
startY: number
logoX: number
logoY: number
logoVx: number
logoVy: number
logoAngle: number
logoAngularV: number
displayX: number
displayY: number
displayVx: number
displayVy: number
displayAngle: number
displayAngularV: number
targetX: number
targetY: number
hidden: boolean
carrierIds: Set<number>
desiredCount: number
swapTimer: number
countTimer: number
slotSeed: number
pauseTimer: number
biasTimer: number
sideBias: number
wobblePhase: number
regripTimer: number
detourTimer: number
detourAngle: number
laggers: Map<number, number>
regripUntil: Map<number, number>
} = {
active: false,
startX: 0,
startY: 0,
logoX: 0,
logoY: 0,
logoVx: 0,
logoVy: 0,
logoAngle: 0,
logoAngularV: 0,
displayX: 0,
displayY: 0,
displayVx: 0,
displayVy: 0,
displayAngle: 0,
displayAngularV: 0,
targetX: 0,
targetY: 0,
hidden: false,
carrierIds: new Set<number>(),
desiredCount: 0,
swapTimer: 0,
countTimer: 0,
slotSeed: 1,
pauseTimer: 0,
biasTimer: 0,
sideBias: 0,
wobblePhase: 0,
regripTimer: 0,
detourTimer: 0,
detourAngle: 0,
laggers: new Map<number, number>(),
regripUntil: new Map<number, number>(),
}
constructor(worldWidth: number, worldHeight: number, initialCount = BUG_DEFAULT_COUNT) {
this.targetCount = clamp(initialCount, 0, BUG_MAX_COUNT)
this.grid = new BugSpatialGrid(BUG_GRID_CELL_SIZE)
this.pheromones = new BugPheromoneField(worldWidth, worldHeight, BUG_PHEROMONE_CELL_SIZE)
for (let i = 0; i < this.targetCount; i++) this.bugs.push(this.createBug(worldWidth, worldHeight))
}
getBugs(): BugEntity[] {
return this.enabled ? this.bugs : []
}
isEnabled(): boolean {
return this.enabled
}
setEnabled(enabled: boolean): void {
this.enabled = enabled
}
setTargetCount(count: number, worldWidth: number, worldHeight: number): void {
this.targetCount = clamp(Math.round(count), 0, BUG_MAX_COUNT)
this.reconcileCount(worldWidth, worldHeight)
}
getTargetCount(): number {
return this.targetCount
}
setCursor(x: number, y: number, active: boolean): void {
this.cursorX = x
this.cursorY = y
this.cursorActive = active
}
startLogoCarry(startX: number, startY: number, targetX: number, targetY: number): void {
for (const b of this.bugs) b.isCarrier = false
this.logoCarry.active = true
this.logoCarry.hidden = false
this.logoCarry.startX = startX
this.logoCarry.startY = startY
this.logoCarry.logoX = startX
this.logoCarry.logoY = startY
this.logoCarry.logoVx = 0
this.logoCarry.logoVy = 0
this.logoCarry.logoAngle = 0
this.logoCarry.logoAngularV = 0
this.logoCarry.displayX = startX
this.logoCarry.displayY = startY
this.logoCarry.displayVx = 0
this.logoCarry.displayVy = 0
this.logoCarry.displayAngle = 0
this.logoCarry.displayAngularV = 0
this.logoCarry.targetX = targetX
this.logoCarry.targetY = targetY
this.logoCarry.carrierIds.clear()
this.logoCarry.slotSeed = Math.floor(Math.random() * 1_000_000) + 1
this.logoCarry.countTimer = randomRange(1.4, 2.6)
this.logoCarry.swapTimer = randomRange(1.8, 2.8)
this.logoCarry.desiredCount = LOGO_CARRY_FIXED_COUNT
this.logoCarry.pauseTimer = randomRange(0.6, 1.8)
this.logoCarry.biasTimer = randomRange(0.8, 1.6)
this.logoCarry.sideBias = randomRange(-0.6, 0.6)
this.logoCarry.wobblePhase = randomRange(0, Math.PI * 2)
this.logoCarry.regripTimer = randomRange(0.5, 1.1)
this.logoCarry.detourTimer = randomRange(1.3, 2.4)
this.logoCarry.detourAngle = 0
this.logoCarry.laggers.clear()
this.logoCarry.regripUntil.clear()
const need = LOGO_CARRY_FIXED_COUNT
while (this.bugs.length < need) {
this.bugs.push(this.createBug(Math.max(targetX, startX) + 120, Math.max(targetY, startY) + 120))
}
const sorted = [...this.bugs].sort((a, b) => {
const da = Math.hypot(a.x - startX, a.y - startY)
const db = Math.hypot(b.x - startX, b.y - startY)
return da - db
})
for (let i = 0; i < need && i < sorted.length; i++) {
const b = sorted[i]
b.isCarrier = true
b.followingTrail = false
this.snapCarrierNearLogo(b)
this.logoCarry.carrierIds.add(b.id)
}
}
stopLogoCarry(): void {
this.logoCarry.active = false
this.logoCarry.hidden = false
this.logoCarry.carrierIds.clear()
this.logoCarry.laggers.clear()
this.logoCarry.regripUntil.clear()
for (const b of this.bugs) b.isCarrier = false
}
getLogoCarryVisual(): { active: boolean; dx: number; dy: number; angle: number; hidden: boolean } {
return {
active: this.logoCarry.active,
dx: this.logoCarry.displayX - this.logoCarry.startX,
dy: this.logoCarry.displayY - this.logoCarry.startY,
angle: this.logoCarry.displayAngle,
hidden: this.logoCarry.hidden,
}
}
update(dt: number, worldWidth: number, worldHeight: number): void {
this.reconcileCount(worldWidth, worldHeight)
if (!this.enabled || dt <= 0) return
this.pheromones.resize(worldWidth, worldHeight)
this.pheromones.update(dt, BUG_PHEROMONE_EVAPORATION_PER_SEC)
this.updateLogoCarryCrew(dt, worldWidth, worldHeight)
const centerX = worldWidth / 2
const centerY = worldHeight / 2
this.grid.clear()
for (const bug of this.bugs) this.grid.add(bug)
for (const bug of this.bugs) {
bug.wanderTimer -= dt
if (bug.wanderTimer <= 0) {
const wanderRange = bug.behaviorType === 'loner' ? 110 : 65
bug.wanderTargetHeading = bug.heading + randomRange(-wanderRange, wanderRange) * Math.PI / 180
bug.wanderTimer = randomRange(BUG_WANDER_MIN_SEC, BUG_WANDER_MAX_SEC)
}
const neighbors = this.grid.query(bug.x, bug.y, BUG_PERCEPTION_RADIUS)
const carryHeading = bug.isCarrier && this.logoCarry.active
? this.computeCarryHeading(bug)
: null
const boidsHeading = carryHeading ?? this.computeBoidsHeading(bug, neighbors, worldWidth, worldHeight, dt)
const blendedTargetHeading = boidsHeading ?? bug.wanderTargetHeading
const turnRate = bug.isCarrier ? 7.5 : (bug.behaviorType === 'loner' ? 5.0 : 3.5)
const angleDelta = normalizeRadians(blendedTargetHeading - bug.heading)
bug.heading += angleDelta * Math.min(1, dt * turnRate)
if (bug.behaviorType === 'edgeDweller') {
const nearestEdgeX = bug.x < centerX ? 0 : worldWidth
const nearestEdgeY = bug.y < centerY ? 0 : worldHeight
const edgeDir = Math.abs(bug.x - centerX) > Math.abs(bug.y - centerY)
? Math.atan2(0, nearestEdgeX - bug.x)
: Math.atan2(nearestEdgeY - bug.y, 0)
bug.heading += normalizeRadians(edgeDir - bug.heading) * Math.min(1, dt * 1.4)
}
const speed = bug.isCarrier
? Math.max(bug.speed, BUG_MAX_SPEED * 0.95) * this.getCarrySpeedMultiplier(bug)
: bug.speed
bug.vx = Math.cos(bug.heading) * speed
bug.vy = Math.sin(bug.heading) * speed
bug.x += bug.vx * dt
bug.y += bug.vy * dt
this.pheromones.deposit(
bug.x,
bug.y,
Math.cos(bug.heading),
Math.sin(bug.heading),
BUG_PHEROMONE_DEPOSIT_PER_SEC * dt * this.getDepositScale(bug.behaviorType),
BUG_PHEROMONE_MAX_STRENGTH,
)
const minX = -BUG_OUT_OF_BOUNDS_MARGIN
const maxX = Math.max(minX, worldWidth + BUG_OUT_OF_BOUNDS_MARGIN)
const minY = -BUG_OUT_OF_BOUNDS_MARGIN
const maxY = Math.max(minY, worldHeight + BUG_OUT_OF_BOUNDS_MARGIN)
if (bug.x <= minX || bug.x >= maxX) {
bug.x = clamp(bug.x, minX, maxX)
bug.heading = Math.PI - bug.heading + randomRange(-0.25, 0.25)
}
if (bug.y <= minY || bug.y >= maxY) {
bug.y = clamp(bug.y, minY, maxY)
bug.heading = -bug.heading + randomRange(-0.25, 0.25)
}
bug.renderHeading = bug.heading
if (bug.isCarrier && this.logoCarry.active) {
// Visual heading stays inward toward payload; movement heading keeps coordination behavior.
bug.renderHeading = this.computeCarryFaceHeading(bug)
}
if (bug.speed > 0.1) {
bug.gaitTimer += BUG_LEG_UPDATE_INTERVAL_SEC * (bug.speed / BUG_MAX_SPEED) * 8.0
if (bug.speed < BUG_MAX_SPEED * 0.5) bug.gaitTimer += BUG_LEG_UPDATE_INTERVAL_SEC * 0.8
if (bug.speed > BUG_MAX_SPEED * 1.2) bug.gaitTimer += BUG_LEG_UPDATE_INTERVAL_SEC * 2.0
if (bug.gaitTimer > 1.0) {
bug.gaitTimer = 0
bug.activeLegGroup = bug.activeLegGroup === 0 ? 1 : 0
}
}
bug.legUpdateTimer += dt
if (bug.legUpdateTimer >= BUG_LEG_UPDATE_INTERVAL_SEC) {
this.updateLegs(bug, BUG_LEG_UPDATE_INTERVAL_SEC)
bug.legUpdateTimer = 0
}
}
this.updateLogoCarryVisual(dt, worldWidth, worldHeight)
}
private updateLegs(bug: BugEntity, dt: number): void {
const size = bug.size
const speedDirX = Math.cos(bug.heading)
const speedDirY = Math.sin(bug.heading)
for (const leg of bug.legs) {
const anchor = localToWorld(
bug.x, bug.y, bug.heading,
leg.anchorLocalX * size,
leg.anchorLocalY * size,
)
const desired = localToWorld(
bug.x, bug.y, bug.heading,
leg.restLocalX * size,
leg.restLocalY * size,
)
if (!leg.stepping) {
const dx = desired.x - leg.footX
const dy = desired.y - leg.footY
const dist = Math.hypot(dx, dy)
const canStep = bug.activeLegGroup === 0
? (leg.index === 0 || leg.index === 4 || leg.index === 2)
: (leg.index === 1 || leg.index === 3 || leg.index === 5)
if (dist > BUG_STEP_TRIGGER * size && canStep) {
leg.stepping = true
leg.stepT = 0
leg.stepDuration = BUG_STEP_DURATION_SEC
leg.stepFromX = leg.footX
leg.stepFromY = leg.footY
leg.stepToX = desired.x + speedDirX * BUG_STEP_DISTANCE * size
leg.stepToY = desired.y + speedDirY * BUG_STEP_DISTANCE * size
}
}
if (leg.stepping) {
// Keep parity with OpenBug: stepping progress advances with a fixed ~60fps tick.
leg.stepT = Math.min(1, leg.stepT + (1 / leg.stepDuration) * 0.016)
leg.footX = leg.stepFromX + (leg.stepToX - leg.stepFromX) * leg.stepT
leg.footY = leg.stepFromY + (leg.stepToY - leg.stepFromY) * leg.stepT
if (leg.stepT >= 1) leg.stepping = false
}
const knee = solveTwoBoneIK(
anchor.x, anchor.y,
leg.footX, leg.footY,
BUG_LEG_SEGMENT_1 * size,
BUG_LEG_SEGMENT_2 * size,
leg.isRightSide,
)
leg.kneeX = knee.x
leg.kneeY = knee.y
}
}
private reconcileCount(worldWidth: number, worldHeight: number): void {
while (this.bugs.length < this.targetCount) this.bugs.push(this.createBug(worldWidth, worldHeight))
while (this.bugs.length > this.targetCount) this.bugs.pop()
}
private computeBoidsHeading(
bug: BugEntity,
nearby: BugEntity[],
worldWidth: number,
worldHeight: number,
dt: number,
): number | null {
let sepX = 0
let sepY = 0
let aliX = 0
let aliY = 0
let cohX = 0
let cohY = 0
let nCount = 0
let aCount = 0
const perceptionSq = BUG_PERCEPTION_RADIUS * BUG_PERCEPTION_RADIUS
const separationSq = BUG_SEPARATION_RADIUS * BUG_SEPARATION_RADIUS
for (const other of nearby) {
if (other.id === bug.id) continue
const dx = bug.x - other.x
const dy = bug.y - other.y
const distSq = dx * dx + dy * dy
if (distSq <= 0.0001 || distSq > perceptionSq) continue
nCount++
cohX += other.x
cohY += other.y
aliX += Math.cos(other.heading)
aliY += Math.sin(other.heading)
aCount++
if (distSq < separationSq) {
const inv = 1 / Math.sqrt(distSq)
sepX += dx * inv
sepY += dy * inv
}
}
const wanderX = Math.cos(bug.wanderTargetHeading)
const wanderY = Math.sin(bug.wanderTargetHeading)
let sepW = BUG_SEPARATION_WEIGHT
let aliW = BUG_ALIGNMENT_WEIGHT
let cohW = BUG_COHESION_WEIGHT
let edgeW = BUG_EDGE_ATTRACTION_WEIGHT
// Behavior-specific weighting close to OpenBug intent.
if (bug.behaviorType === 'social') {
sepW *= 1.0
aliW *= 1.6
cohW *= 1.8
edgeW *= 0.4
} else if (bug.behaviorType === 'loner') {
sepW *= 1.3
aliW *= 0.4
cohW *= 0.25
edgeW *= 0.4
} else {
sepW *= 1.05
aliW *= 0.7
cohW *= 0.55
edgeW *= 1.2
}
const sample = this.pheromones.sampleDirection(bug.x, bug.y, BUG_TRAIL_FOLLOW_RADIUS)
const enterFollowP = Math.min(1, BUG_TRAIL_FOLLOW_PROB * (dt / 0.016))
const breakFollowP = Math.min(1, BUG_TRAIL_BREAK_PROB * (dt / 0.016))
if (!bug.followingTrail && sample.strength > BUG_PHEROMONE_MIN_DETECT && Math.random() < enterFollowP) {
bug.followingTrail = true
} else if (bug.followingTrail && Math.random() < breakFollowP) {
bug.followingTrail = false
}
let fx = wanderX
let fy = wanderY
fx += sepX * sepW
fy += sepY * sepW
if (aCount > 0) {
fx += (aliX / aCount) * aliW
fy += (aliY / aCount) * aliW
}
if (nCount > 0) {
const cx = cohX / nCount
const cy = cohY / nCount
const toCx = cx - bug.x
const toCy = cy - bug.y
const clen = Math.hypot(toCx, toCy)
if (clen > 0.001) {
fx += (toCx / clen) * cohW
fy += (toCy / clen) * cohW
}
}
if (sample.strength > BUG_PHEROMONE_MIN_DETECT) {
const plen = Math.hypot(sample.x, sample.y)
if (plen > 0.0001) {
const followW = bug.followingTrail ? BUG_TRAIL_STEERING_WEIGHT : BUG_PHEROMONE_FOLLOW_WEIGHT
fx += (sample.x / plen) * followW
fy += (sample.y / plen) * followW
}
}
// Mouse repulsion: bugs near cursor scatter outward.
if (this.cursorActive && !bug.isCarrier) {
const dx = bug.x - this.cursorX
const dy = bug.y - this.cursorY
const distSq = dx * dx + dy * dy
const radSq = BUG_CURSOR_REPULSION_RADIUS * BUG_CURSOR_REPULSION_RADIUS
if (distSq > 0.0001 && distSq < radSq) {
const dist = Math.sqrt(distSq)
const nx = dx / dist
const ny = dy / dist
const repulse = BUG_CURSOR_REPULSION_STRENGTH * (1 - dist / BUG_CURSOR_REPULSION_RADIUS)
fx += nx * repulse * 10.0
fy += ny * repulse * 10.0
}
}
if (bug.behaviorType === 'edgeDweller') {
const left = bug.x
const right = worldWidth - bug.x
const top = bug.y
const bottom = worldHeight - bug.y
const minD = Math.min(left, right, top, bottom)
if (minD > 0) {
if (minD === left) fx -= edgeW
else if (minD === right) fx += edgeW
else if (minD === top) fy -= edgeW
else fy += edgeW
}
}
if (Math.abs(fx) < 0.0001 && Math.abs(fy) < 0.0001) return null
return Math.atan2(fy, fx)
}
private updateLogoCarryVisual(dt: number, worldWidth: number, worldHeight: number): void {
if (!this.logoCarry.active) return
const toTargetX = this.logoCarry.targetX - this.logoCarry.logoX
const toTargetY = this.logoCarry.targetY - this.logoCarry.logoY
const toTargetLen = Math.hypot(toTargetX, toTargetY)
const targetHeading = Math.atan2(toTargetY, toTargetX) + this.logoCarry.detourAngle
const tnx = Math.cos(targetHeading)
const tny = Math.sin(targetHeading)
const pnx = -tny
const pny = tnx
const engageRadius = 12
let pull = 0
let lateral = 0
let torque = 0
let n = 0
for (const b of this.bugs) {
if (!b.isCarrier) continue
const dx = this.logoCarry.logoX - b.x
const dy = this.logoCarry.logoY - b.y
const dist = Math.hypot(dx, dy)
if (dist <= engageRadius) {
const grip = 1 - dist / engageRadius
const rx = b.x - this.logoCarry.logoX
const ry = b.y - this.logoCarry.logoY
const behindness = clamp((-(rx * tnx + ry * tny)) / (engageRadius * 1.1), -1, 1)
const sideFactor = clamp(Math.abs(rx * pnx + ry * pny) / engageRadius, 0, 1)
const alignForPull = clamp(0.18 + Math.max(0, behindness) * 1.05, 0.1, 1.25)
const alignForSide = 0.15 + sideFactor * 0.65
const alignForTorque = 0.2 + sideFactor * 0.8
pull += alignForPull * grip
const side = rx * pnx + ry * pny
lateral += Math.sign(side) * alignForSide * grip
torque += side * alignForTorque * (0.5 + Math.max(0, behindness) * 0.8) * grip * 0.12
}
n++
}
if (n > 0 && toTargetLen > 0.0001) {
const mass = 1.5
const damping = 0.86
this.logoCarry.wobblePhase += dt * 7.5
let drive = pull * 24
if (this.logoCarry.pauseTimer > 0) drive *= 0.2
const wobble = Math.sin(this.logoCarry.wobblePhase) * 0.55
const latDrive = lateral * 5.5 + this.logoCarry.sideBias * 1.2 + wobble
this.logoCarry.logoVx = this.logoCarry.logoVx * damping + (tnx * drive + pnx * latDrive) / mass
this.logoCarry.logoVy = this.logoCarry.logoVy * damping + (tny * drive + pny * latDrive) / mass
const angularDamping = 0.84
const angularDrive = torque * 0.075 + wobble * 0.15
this.logoCarry.logoAngularV = this.logoCarry.logoAngularV * angularDamping + angularDrive
this.logoCarry.logoAngle += this.logoCarry.logoAngularV * dt
this.logoCarry.logoAngle = clamp(this.logoCarry.logoAngle, -0.75, 0.75)
this.logoCarry.logoX += this.logoCarry.logoVx * dt
this.logoCarry.logoY += this.logoCarry.logoVy * dt
} else {
this.logoCarry.logoVx *= 0.85
this.logoCarry.logoVy *= 0.85
this.logoCarry.logoAngularV *= 0.82
this.logoCarry.logoAngle += this.logoCarry.logoAngularV * dt
}
// Keep visual center fully aligned with physical payload center.
this.logoCarry.displayVx = this.logoCarry.logoVx
this.logoCarry.displayVy = this.logoCarry.logoVy
this.logoCarry.displayX = this.logoCarry.logoX
this.logoCarry.displayY = this.logoCarry.logoY
const angleLead = clamp(this.logoCarry.logoAngularV * 0.04, -0.05, 0.05)
const visAngleTarget = this.logoCarry.logoAngle + angleLead
const angleSpring = 70
const angleDrag = 0.92
this.logoCarry.displayAngularV += (visAngleTarget - this.logoCarry.displayAngle) * angleSpring * dt
this.logoCarry.displayAngularV *= angleDrag
this.logoCarry.displayAngle += this.logoCarry.displayAngularV * dt
this.logoCarry.displayAngle = clamp(this.logoCarry.displayAngle, -0.95, 0.95)
if (this.logoCarry.logoX > worldWidth + 28 || this.logoCarry.logoY > worldHeight + 28) {
this.logoCarry.hidden = true
}
}
private updateLogoCarryCrew(dt: number, worldWidth: number, worldHeight: number): void {
if (!this.logoCarry.active) return
this.logoCarry.countTimer -= dt
this.logoCarry.swapTimer -= dt
this.logoCarry.pauseTimer -= dt
this.logoCarry.biasTimer -= dt
this.logoCarry.regripTimer -= dt
this.logoCarry.detourTimer -= dt
if (this.logoCarry.detourAngle !== 0) {
this.logoCarry.detourAngle *= Math.max(0, 1 - dt * 2.2)
if (Math.abs(this.logoCarry.detourAngle) < 0.02) this.logoCarry.detourAngle = 0
}
if (this.logoCarry.pauseTimer <= 0) {
// Ant-like re-grip: tiny pauses while carrying.
this.logoCarry.pauseTimer = Math.random() < 0.35 ? randomRange(0.08, 0.2) : randomRange(0.55, 1.4)
}
if (this.logoCarry.biasTimer <= 0) {
// Side bias drifts over time, creating imperfect trajectories.
this.logoCarry.sideBias = randomRange(-0.8, 0.8)
this.logoCarry.biasTimer = randomRange(0.7, 1.6)
}
if (this.logoCarry.detourTimer <= 0) {
// Short detours like ants negotiating tiny obstacles.
this.logoCarry.detourAngle = randomRange(-0.38, 0.38)
this.logoCarry.detourTimer = randomRange(1.1, 2.1)
}
if (this.logoCarry.countTimer <= 0) {
this.logoCarry.desiredCount = LOGO_CARRY_FIXED_COUNT
this.logoCarry.countTimer = randomRange(1.2, 2.4)
this.logoCarry.slotSeed = Math.floor(randomRange(1, 1_000_000))
}
const need = LOGO_CARRY_FIXED_COUNT
while (this.bugs.length < need) this.bugs.push(this.createBug(worldWidth, worldHeight))
const currentCarriers = this.bugs.filter((b) => b.isCarrier)
if (currentCarriers.length > need) {
const drop = currentCarriers
.sort((a, b) => Math.random() - 0.5)
.slice(0, currentCarriers.length - need)
for (const b of drop) {
b.isCarrier = false
this.logoCarry.carrierIds.delete(b.id)
}
} else if (currentCarriers.length < need) {
const missing = need - currentCarriers.length
const candidates = this.bugs
.filter((b) => !b.isCarrier)
.sort((a, b) => {
const da = Math.hypot(a.x - this.logoCarry.logoX, a.y - this.logoCarry.logoY)
const db = Math.hypot(b.x - this.logoCarry.logoX, b.y - this.logoCarry.logoY)
return da - db
})
.slice(0, missing)
for (const b of candidates) {
b.isCarrier = true
b.followingTrail = false
this.snapCarrierNearLogo(b)
this.logoCarry.carrierIds.add(b.id)
}
}
if (this.logoCarry.swapTimer <= 0) {
const carriers = this.bugs.filter((b) => b.isCarrier)
const canDrop = Math.max(1, carriers.length - LOGO_CARRY_FIXED_COUNT)
const dropCount = Math.min(canDrop, Math.floor(randomRange(1, 3)))
const drop = carriers.sort((a, b) => Math.random() - 0.5).slice(0, dropCount)
for (const b of drop) {
b.isCarrier = false
this.logoCarry.carrierIds.delete(b.id)
}
const refill = this.bugs
.filter((b) => !b.isCarrier)
.sort((a, b) => {
const da = Math.hypot(a.x - this.logoCarry.logoX, a.y - this.logoCarry.logoY)
const db = Math.hypot(b.x - this.logoCarry.logoX, b.y - this.logoCarry.logoY)
return da - db
})
.slice(0, dropCount)
for (const b of refill) {
b.isCarrier = true
b.followingTrail = false
this.snapCarrierNearLogo(b)
this.logoCarry.carrierIds.add(b.id)
}
// One carrier may lag briefly, then catch up to reform the group.
const stillCarriers = this.bugs.filter((b) => b.isCarrier)
if (stillCarriers.length > 0) {
const lagger = stillCarriers[Math.floor(Math.random() * stillCarriers.length)]
lagger.x += randomRange(-14, 14)
lagger.y += randomRange(-14, 14)
this.logoCarry.laggers.set(lagger.id, randomRange(0.35, 0.9))
}
this.logoCarry.swapTimer = randomRange(0.7, 1.5)
}
if (this.logoCarry.regripTimer <= 0) {
const carriers = this.bugs.filter((b) => b.isCarrier)
if (carriers.length > 0) {
const regripCount = Math.min(2, carriers.length)
const shuffled = carriers.sort(() => Math.random() - 0.5)
for (let i = 0; i < regripCount; i++) {
const b = shuffled[i]
this.logoCarry.regripUntil.set(b.id, randomRange(0.12, 0.32))
}
}
this.logoCarry.regripTimer = randomRange(0.5, 1.2)
}
// Countdown transient states.
for (const [id, left] of [...this.logoCarry.laggers.entries()]) {
const next = left - dt
if (next <= 0) this.logoCarry.laggers.delete(id)
else this.logoCarry.laggers.set(id, next)
}
for (const [id, left] of [...this.logoCarry.regripUntil.entries()]) {
const next = left - dt
if (next <= 0) this.logoCarry.regripUntil.delete(id)
else this.logoCarry.regripUntil.set(id, next)
}
}
private computeCarryHeading(bug: BugEntity): number {
const toTargetX = this.logoCarry.targetX - this.logoCarry.logoX
const toTargetY = this.logoCarry.targetY - this.logoCarry.logoY
const targetHeading = Math.atan2(toTargetY, toTargetX)
const tnx = Math.cos(targetHeading)
const tny = Math.sin(targetHeading)
const pnx = -tny
const pny = tnx
const n = Math.max(1, this.logoCarry.carrierIds.size)
const baseSlot = ((bug.id * 2654435761 + this.logoCarry.slotSeed) >>> 0) % n
const slotPhase = n <= 1 ? 0 : (baseSlot / n) * Math.PI * 2
const jitterRng = (((bug.id * 1664525 + this.logoCarry.slotSeed) >>> 0) % 1000) / 1000 - 0.5
// True ring placement: workers distribute around the whole payload.
const slotHeading = targetHeading + slotPhase + jitterRng * 0.22
const ringR = 7.4 + (((bug.id * 1103515245 + this.logoCarry.slotSeed) >>> 0) % 4) * 0.75
const slotX = this.logoCarry.logoX + Math.cos(slotHeading) * ringR
const slotY = this.logoCarry.logoY + Math.sin(slotHeading) * ringR
const toSlotX = slotX - bug.x
const toSlotY = slotY - bug.y
const distToSlot = Math.hypot(toSlotX, toSlotY)
const lagging = (this.logoCarry.laggers.get(bug.id) ?? 0) > 0
const pullInDist = lagging ? 2.2 : 3.2
if (distToSlot > pullInDist) {
const slotHeadingNow = Math.atan2(toSlotY, toSlotX)
return slotHeadingNow
}
// On-ring collaboration flow: advance with payload + slight orbit + radius correction.
const rx = bug.x - this.logoCarry.logoX
const ry = bug.y - this.logoCarry.logoY
const rLen = Math.hypot(rx, ry)
const rnx = rLen > 0.001 ? rx / rLen : Math.cos(slotHeading)
const rny = rLen > 0.001 ? ry / rLen : Math.sin(slotHeading)
const tangentSign = (baseSlot % 2 === 0) ? 1 : -1
const tx = -rny * tangentSign
const ty = rnx * tangentSign
const desiredR = ringR
const radiusErr = desiredR - rLen
const corrX = rnx * radiusErr * 0.9
const corrY = rny * radiusErr * 0.9
const moveX = tnx * 1.08 + tx * 0.42 + corrX + pnx * jitterRng * 0.08
const moveY = tny * 1.08 + ty * 0.42 + corrY + pny * jitterRng * 0.08
const mixX = moveX
const mixY = moveY
return Math.atan2(mixY, mixX)
}
private computeCarryFaceHeading(bug: BugEntity): number {
const toLogoX = this.logoCarry.logoX - bug.x
const toLogoY = this.logoCarry.logoY - bug.y
const base = Math.atan2(toLogoY, toLogoX)
const jitter = (((bug.id * 214013 + this.logoCarry.slotSeed) >>> 0) % 1000) / 1000 - 0.5
return base + jitter * 0.02
}
private snapCarrierNearLogo(bug: BugEntity): void {
const toTargetX = this.logoCarry.targetX - this.logoCarry.logoX
const toTargetY = this.logoCarry.targetY - this.logoCarry.logoY
const targetHeading = Math.atan2(toTargetY, toTargetX)
const n = Math.max(1, this.logoCarry.desiredCount)
const slot = ((bug.id * 2654435761 + this.logoCarry.slotSeed) >>> 0) % n
const slotPhase = n <= 1 ? 0 : (slot / n) * Math.PI * 2
const r = 8.2 + randomRange(-0.8, 0.8)
const h = targetHeading + slotPhase
bug.x = this.logoCarry.logoX + Math.cos(h) * r
bug.y = this.logoCarry.logoY + Math.sin(h) * r
bug.heading = targetHeading + randomRange(-0.18, 0.18)
bug.renderHeading = this.computeCarryFaceHeading(bug)
this.resetLegPose(bug)
}
private resetLegPose(bug: BugEntity): void {
const size = bug.size
for (const leg of bug.legs) {
const foot = localToWorld(
bug.x,
bug.y,
bug.heading,
leg.restLocalX * size,
leg.restLocalY * size,
)
leg.footX = foot.x
leg.footY = foot.y
leg.stepFromX = foot.x
leg.stepFromY = foot.y
leg.stepToX = foot.x
leg.stepToY = foot.y
leg.stepT = 0
leg.stepping = false
const anchor = localToWorld(
bug.x,
bug.y,
bug.heading,
leg.anchorLocalX * size,
leg.anchorLocalY * size,
)
const knee = solveTwoBoneIK(
anchor.x,
anchor.y,
leg.footX,
leg.footY,
BUG_LEG_SEGMENT_1 * size,
BUG_LEG_SEGMENT_2 * size,
leg.isRightSide,
)
leg.kneeX = knee.x
leg.kneeY = knee.y
}
}
private getCarrySpeedMultiplier(bug: BugEntity): number {
const roleCode = this.getCarryRoleCode(bug.id)
const regrip = (this.logoCarry.regripUntil.get(bug.id) ?? 0) > 0
const lagging = (this.logoCarry.laggers.get(bug.id) ?? 0) > 0
let scale = 1
if (roleCode < 5) scale = 1.03
else if (roleCode < 8) scale = 0.96
else scale = 0.92
if (lagging) scale *= 1.12
if (regrip) scale *= 0.62
return scale
}
private getCarryRoleCode(id: number): number {
return ((id * 1103515245 + this.logoCarry.slotSeed) >>> 0) % 10
}
private getDepositScale(behavior: BugBehaviorType): number {
if (behavior === 'loner') return 1.6
if (behavior === 'social') return 1.0
return 0.25
}
private createBug(worldWidth: number, worldHeight: number): BugEntity {
const behaviorType = pickBehavior()
const size = pickSizeScale()
const speedBias = behaviorType === 'loner' ? 1.15 : behaviorType === 'social' ? 0.95 : 0.9
const speed = randomRange(BUG_MIN_SPEED, BUG_MAX_SPEED) * speedBias
const heading = randomRange(0, Math.PI * 2)
const x = randomRange(BUG_BORDER_PADDING, Math.max(BUG_BORDER_PADDING + 1, worldWidth - BUG_BORDER_PADDING))
const y = randomRange(BUG_BORDER_PADDING, Math.max(BUG_BORDER_PADDING + 1, worldHeight - BUG_BORDER_PADDING))
const legs: BugLeg[] = LEG_SPECS.map((s, index) => {
const foot = localToWorld(x, y, heading, s.restX * size, s.restY * size)
return {
index,
group: s.group,
sideSign: s.side,
isRightSide: s.side === 1,
anchorLocalX: s.anchorX,
anchorLocalY: s.anchorY,
restLocalX: s.restX,
restLocalY: s.restY,
footX: foot.x,
footY: foot.y,
kneeX: foot.x,
kneeY: foot.y,
stepping: false,
stepT: 0,
stepDuration: BUG_STEP_DURATION_SEC,
stepFromX: foot.x,
stepFromY: foot.y,
stepToX: foot.x,
stepToY: foot.y,
}
})
// Initialize knees once.
for (const leg of legs) {
const a = localToWorld(x, y, heading, leg.anchorLocalX * size, leg.anchorLocalY * size)
const k = solveTwoBoneIK(a.x, a.y, leg.footX, leg.footY, BUG_LEG_SEGMENT_1 * size, BUG_LEG_SEGMENT_2 * size, leg.isRightSide)
leg.kneeX = k.x
leg.kneeY = k.y
}
const color = BUG_MORANDI_COLORS[Math.floor(Math.random() * BUG_MORANDI_COLORS.length)]
return {
id: this.nextId++,
x,
y,
vx: Math.cos(heading) * speed,
vy: Math.sin(heading) * speed,
heading,
renderHeading: heading,
speed,
size,
wanderTimer: randomRange(BUG_WANDER_MIN_SEC, BUG_WANDER_MAX_SEC),
wanderTargetHeading: heading,
behaviorType,
color,
activeLegGroup: Math.random() < 0.5 ? 0 : 1,
gaitTimer: randomRange(0, BUG_GAIT_SWITCH_SEC),
legUpdateTimer: 0,
followingTrail: false,
isCarrier: false,
legs,
}
}
}
// Expose body dimensions for renderer to stay in sync with motion profile.
export const BUG_BODY_DIMENSIONS = {
head: BUG_HEAD_SIZE,
thoraxLength: BUG_THORAX_LENGTH,
thoraxWidth: BUG_THORAX_WIDTH,
abdomenLength: BUG_ABDOMEN_LENGTH,
abdomenWidth: BUG_ABDOMEN_WIDTH,
}