From a106d8002ace61fb0c1f62a367cdb0e47bf88023 Mon Sep 17 00:00:00 2001 From: xmanrui <841206367@qq.com> Date: Sun, 1 Mar 2026 03:33:18 +0800 Subject: [PATCH] feat(pixel-office): global bug controls and ant-like logo carry behavior --- app/global-bugs-overlay.tsx | 124 +++ app/layout.tsx | 2 + app/pixel-office/page.tsx | 11 +- app/sidebar.tsx | 142 +++- lib/pixel-office/bugs/bugSystem.ts | 1027 +++++++++++++++++++++++ lib/pixel-office/bugs/config.ts | 74 ++ lib/pixel-office/bugs/pheromoneField.ts | 106 +++ lib/pixel-office/bugs/renderer.ts | 118 +++ lib/pixel-office/bugs/spatialGrid.ts | 48 ++ lib/pixel-office/bugs/types.ts | 45 + lib/pixel-office/engine/officeState.ts | 39 + lib/pixel-office/engine/renderer.ts | 7 + 12 files changed, 1739 insertions(+), 4 deletions(-) create mode 100644 app/global-bugs-overlay.tsx create mode 100644 lib/pixel-office/bugs/bugSystem.ts create mode 100644 lib/pixel-office/bugs/config.ts create mode 100644 lib/pixel-office/bugs/pheromoneField.ts create mode 100644 lib/pixel-office/bugs/renderer.ts create mode 100644 lib/pixel-office/bugs/spatialGrid.ts create mode 100644 lib/pixel-office/bugs/types.ts diff --git a/app/global-bugs-overlay.tsx b/app/global-bugs-overlay.tsx new file mode 100644 index 0000000..3538423 --- /dev/null +++ b/app/global-bugs-overlay.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { BugSystem } from "@/lib/pixel-office/bugs/bugSystem"; +import { renderBugs } from "@/lib/pixel-office/bugs/renderer"; + +const BUGS_ENABLED_KEY = "pixel-office-bugs-enabled"; +const BUGS_COUNT_KEY = "pixel-office-bugs-count"; +const BUGS_MAX = 400; +const BUGS_ZOOM = 2.5; + +function readConfig(): { enabled: boolean; count: number } { + const enabled = localStorage.getItem(BUGS_ENABLED_KEY) === "true"; + const raw = Number(localStorage.getItem(BUGS_COUNT_KEY) || "5"); + const count = Math.max(0, Math.min(BUGS_MAX, Number.isFinite(raw) ? raw : 5)); + return { enabled, count }; +} + +export function GlobalBugsOverlay() { + const canvasRef = useRef(null); + const systemRef = useRef(null); + const enabledRef = useRef(false); + const countRef = useRef(5); + const mouseRef = useRef({ x: 0, y: 0, active: false }); + const rafRef = useRef(null); + + useEffect(() => { + if (!canvasRef.current) return; + const canvas = canvasRef.current; + const initial = readConfig(); + enabledRef.current = initial.enabled; + countRef.current = initial.count; + + const system = new BugSystem(window.innerWidth / BUGS_ZOOM, window.innerHeight / BUGS_ZOOM, initial.count); + system.setEnabled(initial.enabled); + system.setTargetCount(initial.count, window.innerWidth / BUGS_ZOOM, window.innerHeight / BUGS_ZOOM); + systemRef.current = system; + + const applyConfig = () => { + if (!systemRef.current) return; + const cfg = readConfig(); + enabledRef.current = cfg.enabled; + countRef.current = cfg.count; + systemRef.current.setEnabled(cfg.enabled); + systemRef.current.setTargetCount(cfg.count, window.innerWidth / BUGS_ZOOM, window.innerHeight / BUGS_ZOOM); + }; + + const onStorage = () => applyConfig(); + const onConfigChanged = () => applyConfig(); + const onLogoDragStart = () => { + if (!systemRef.current) return; + // Sidebar logo anchor (approximate screen space), target off-screen bottom-right. + const startX = 58 / BUGS_ZOOM; + const startY = 42 / BUGS_ZOOM; + const targetX = (window.innerWidth + 180) / BUGS_ZOOM; + const targetY = (window.innerHeight + 160) / BUGS_ZOOM; + systemRef.current.startLogoCarry(startX, startY, targetX, targetY); + }; + const onLogoDragStop = () => { + if (!systemRef.current) return; + systemRef.current.stopLogoCarry(); + window.dispatchEvent(new CustomEvent("openclaw-logo-carry-progress", { + detail: { dx: 0, dy: 0, angle: 0, hidden: false, active: false }, + })); + }; + const onMove = (e: MouseEvent) => { + mouseRef.current = { x: e.clientX, y: e.clientY, active: true }; + }; + const onLeave = () => { + mouseRef.current.active = false; + }; + + window.addEventListener("storage", onStorage); + window.addEventListener("openclaw-bugs-config-change", onConfigChanged as EventListener); + window.addEventListener("openclaw-logo-drag-start", onLogoDragStart as EventListener); + window.addEventListener("openclaw-logo-drag-stop", onLogoDragStop as EventListener); + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseout", onLeave); + + let last = 0; + const tick = (ts: number) => { + const dt = last === 0 ? 0 : Math.min((ts - last) / 1000, 0.1); + last = ts; + const w = window.innerWidth; + const h = window.innerHeight; + const dpr = window.devicePixelRatio || 1; + + if (canvas.width !== Math.floor(w * dpr) || canvas.height !== Math.floor(h * dpr)) { + canvas.width = Math.floor(w * dpr); + canvas.height = Math.floor(h * dpr); + canvas.style.width = `${w}px`; + canvas.style.height = `${h}px`; + } + + const ctx = canvas.getContext("2d"); + if (ctx && systemRef.current) { + systemRef.current.setCursor(mouseRef.current.x / BUGS_ZOOM, mouseRef.current.y / BUGS_ZOOM, mouseRef.current.active); + systemRef.current.update(dt, w / BUGS_ZOOM, h / BUGS_ZOOM); + const carry = systemRef.current.getLogoCarryVisual(); + window.dispatchEvent(new CustomEvent("openclaw-logo-carry-progress", { + detail: { dx: carry.dx * BUGS_ZOOM, dy: carry.dy * BUGS_ZOOM, angle: carry.angle, hidden: carry.hidden, active: carry.active }, + })); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, w, h); + renderBugs(ctx, systemRef.current.getBugs(), 0, 0, BUGS_ZOOM); + } + + rafRef.current = requestAnimationFrame(tick); + }; + rafRef.current = requestAnimationFrame(tick); + + return () => { + window.removeEventListener("storage", onStorage); + window.removeEventListener("openclaw-bugs-config-change", onConfigChanged as EventListener); + window.removeEventListener("openclaw-logo-drag-start", onLogoDragStart as EventListener); + window.removeEventListener("openclaw-logo-drag-stop", onLogoDragStop as EventListener); + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseout", onLeave); + if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); + }; + }, []); + + return ; +} diff --git a/app/layout.tsx b/app/layout.tsx index 99a20d5..0f59ef5 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,6 +3,7 @@ import "./globals.css"; import { Providers } from "./providers"; import { Sidebar } from "./sidebar"; import { AlertMonitor } from "./alert-monitor"; +import { GlobalBugsOverlay } from "./global-bugs-overlay"; export const metadata: Metadata = { title: "OpenClaw Bot Dashboard", @@ -15,6 +16,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) +
{children}
diff --git a/app/pixel-office/page.tsx b/app/pixel-office/page.tsx index d23361a..99c7721 100644 --- a/app/pixel-office/page.tsx +++ b/app/pixel-office/page.tsx @@ -183,6 +183,13 @@ export default function PixelOfficePage() { } }, []) + useEffect(() => { + window.dispatchEvent(new CustomEvent('openclaw-logo-drag-start')) + return () => { + window.dispatchEvent(new CustomEvent('openclaw-logo-drag-stop')) + } + }, []) + // Game loop useEffect(() => { if (!canvasRef.current || !officeRef.current || !containerRef.current) return @@ -195,7 +202,6 @@ export default function PixelOfficePage() { const render = (time: number) => { const dt = lastTime === 0 ? 0 : Math.min((time - lastTime) / 1000, 0.1) lastTime = time - office.update(dt) const width = container.clientWidth const height = container.clientHeight @@ -203,6 +209,8 @@ export default function PixelOfficePage() { // Fixed zoom: disable runtime zooming for now zoomRef.current = FIXED_CANVAS_ZOOM const dpr = window.devicePixelRatio || 1 + office.update(dt) + canvas.width = width * dpr canvas.height = height * dpr canvas.style.width = `${width}px` @@ -247,6 +255,7 @@ export default function PixelOfficePage() { zoomRef.current, panRef.current.x, panRef.current.y, { selectedAgentId: null, hoveredAgentId, hoveredTile: null, seats: office.seats, characters: office.characters }, editorRender, office.layout.tileColors, office.layout.cols, office.layout.rows, + undefined, contributionsRef.current ?? undefined, photographRef.current ?? undefined) // Collect photo comment positions for DOM rendering diff --git a/app/sidebar.tsx b/app/sidebar.tsx index fe52585..4dc2b55 100644 --- a/app/sidebar.tsx +++ b/app/sidebar.tsx @@ -1,11 +1,15 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { useI18n, LanguageSwitcher } from "@/lib/i18n"; import { ThemeSwitcher } from "@/lib/theme"; +const BUGS_ENABLED_KEY = "pixel-office-bugs-enabled"; +const BUGS_COUNT_KEY = "pixel-office-bugs-count"; +const BUGS_MAX = 400; + const NAV_ITEMS = [ { group: "nav.overview", @@ -35,6 +39,68 @@ export function Sidebar() { const pathname = usePathname(); const { t } = useI18n(); const [collapsed, setCollapsed] = useState(false); + const [experimentOpen, setExperimentOpen] = useState(false); + const [bugsEnabled, setBugsEnabled] = useState(false); + const [bugsCount, setBugsCount] = useState(5); + const [logoCarry, setLogoCarry] = useState<{ active: boolean; dx: number; dy: number; angle: number; hidden: boolean }>({ + active: false, + dx: 0, + dy: 0, + angle: 0, + hidden: false, + }); + + useEffect(() => { + const onStart = () => setLogoCarry((s) => ({ ...s, active: true, hidden: false })); + const onStop = () => setLogoCarry({ active: false, dx: 0, dy: 0, angle: 0, hidden: false }); + const onProgress = (e: Event) => { + const ce = e as CustomEvent<{ active: boolean; dx: number; dy: number; angle: number; hidden: boolean }>; + const d = ce.detail; + if (!d) return; + setLogoCarry({ active: !!d.active, dx: d.dx || 0, dy: d.dy || 0, angle: d.angle || 0, hidden: !!d.hidden }); + }; + window.addEventListener("openclaw-logo-drag-start", onStart as EventListener); + window.addEventListener("openclaw-logo-drag-stop", onStop as EventListener); + window.addEventListener("openclaw-logo-carry-progress", onProgress as EventListener); + return () => { + window.removeEventListener("openclaw-logo-drag-start", onStart as EventListener); + window.removeEventListener("openclaw-logo-drag-stop", onStop as EventListener); + window.removeEventListener("openclaw-logo-carry-progress", onProgress as EventListener); + }; + }, []); + + useEffect(() => { + const syncFromStorage = () => { + const enabled = localStorage.getItem(BUGS_ENABLED_KEY) === "true"; + const raw = Number(localStorage.getItem(BUGS_COUNT_KEY) || "5"); + const count = Math.max(0, Math.min(BUGS_MAX, Number.isFinite(raw) ? raw : 5)); + setBugsEnabled(enabled); + setBugsCount(count); + }; + syncFromStorage(); + window.addEventListener("storage", syncFromStorage); + window.addEventListener("openclaw-bugs-config-change", syncFromStorage as EventListener); + return () => { + window.removeEventListener("storage", syncFromStorage); + window.removeEventListener("openclaw-bugs-config-change", syncFromStorage as EventListener); + }; + }, []); + + const toggleBugs = () => { + const next = !bugsEnabled; + setBugsEnabled(next); + localStorage.setItem(BUGS_ENABLED_KEY, String(next)); + window.dispatchEvent(new CustomEvent("openclaw-bugs-config-change")); + }; + + const onBugCountChange = (nextCount: number) => { + const clamped = Math.max(0, Math.min(BUGS_MAX, nextCount)); + setBugsCount(clamped); + localStorage.setItem(BUGS_COUNT_KEY, String(clamped)); + window.dispatchEvent(new CustomEvent("openclaw-bugs-config-change")); + }; + + const isPixelOfficePage = pathname.startsWith("/pixel-office"); return ( <> @@ -47,7 +113,17 @@ export function Sidebar() { {collapsed ? (
- ๐Ÿฆž + + ๐Ÿฆž + + {experimentOpen && ( +
+ + +
+ )} +
+ )}
diff --git a/lib/pixel-office/bugs/bugSystem.ts b/lib/pixel-office/bugs/bugSystem.ts new file mode 100644 index 0000000..7fce99c --- /dev/null +++ b/lib/pixel-office/bugs/bugSystem.ts @@ -0,0 +1,1027 @@ +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 + desiredCount: number + swapTimer: number + countTimer: number + slotSeed: number + pauseTimer: number + biasTimer: number + sideBias: number + wobblePhase: number + regripTimer: number + detourTimer: number + detourAngle: number + laggers: Map + regripUntil: Map + } = { + 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(), + 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(), + regripUntil: new Map(), + } + + 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, +} diff --git a/lib/pixel-office/bugs/config.ts b/lib/pixel-office/bugs/config.ts new file mode 100644 index 0000000..0be9859 --- /dev/null +++ b/lib/pixel-office/bugs/config.ts @@ -0,0 +1,74 @@ +export const BUG_DEFAULT_COUNT = 5 +export const BUG_MAX_COUNT = 400 +export const BUG_MIN_SPEED = 12 +export const BUG_MAX_SPEED = 30 +export const BUG_WANDER_MIN_SEC = 0.5 +export const BUG_WANDER_MAX_SEC = 2.2 +export const BUG_BORDER_PADDING = 8 + +// OpenBug-like body proportions +export const BUG_HEAD_SIZE = 3.2 +export const BUG_THORAX_LENGTH = 4.0 +export const BUG_THORAX_WIDTH = 2.5 +export const BUG_ABDOMEN_LENGTH = 5.0 +export const BUG_ABDOMEN_WIDTH = 3.5 + +// OpenBug-like legs +export const BUG_LEG_SEGMENT_1 = 2.65 +export const BUG_LEG_SEGMENT_2 = 3.45 +export const BUG_STEP_TRIGGER = 4.0 +export const BUG_STEP_DISTANCE = 2.0 +export const BUG_STEP_HEIGHT = 2.2 +export const BUG_STEP_DURATION_SEC = 0.09 +export const BUG_GAIT_SWITCH_SEC = 0.11 +export const BUG_LEG_UPDATE_INTERVAL_SEC = 0.0416667 +export const BUG_OUT_OF_BOUNDS_MARGIN = 64 + +// M2: neighborhood + boids-like steering +export const BUG_PERCEPTION_RADIUS = 40 +export const BUG_SEPARATION_RADIUS = 8 +export const BUG_GRID_CELL_SIZE = 50 +export const BUG_SEPARATION_WEIGHT = 8.0 +export const BUG_ALIGNMENT_WEIGHT = 1.2 +export const BUG_COHESION_WEIGHT = 1.0 +export const BUG_EDGE_ATTRACTION_WEIGHT = 2.0 + +// M2: lifecycle + swarm event +export const BUG_SPAWN_INTERVAL_SEC = 600 // 10 minutes +export const BUG_SWARM_MIN_COUNT = 20 +export const BUG_SWARM_TRIGGER_MEAN_SEC = 480 +export const BUG_SWARM_DURATION_MIN_SEC = 6 +export const BUG_SWARM_DURATION_MAX_SEC = 14 +export const BUG_SWARM_COOLDOWN_MIN_SEC = 300 +export const BUG_SWARM_COOLDOWN_MAX_SEC = 420 +export const BUG_SWARM_INITIAL_COOLDOWN_MIN_SEC = 90 +export const BUG_SWARM_INITIAL_COOLDOWN_MAX_SEC = 210 + +// M3: pheromone + trail follow +export const BUG_PHEROMONE_CELL_SIZE = 28.0 +export const BUG_PHEROMONE_DEPOSIT_PER_SEC = 6.0 +export const BUG_PHEROMONE_EVAPORATION_PER_SEC = 0.25 +export const BUG_PHEROMONE_MAX_STRENGTH = 12.0 +export const BUG_PHEROMONE_MIN_DETECT = 0.15 +export const BUG_PHEROMONE_FOLLOW_WEIGHT = 2.0 +export const BUG_TRAIL_FOLLOW_RADIUS = 40.0 +export const BUG_TRAIL_FOLLOW_PROB = 0.02 +export const BUG_TRAIL_BREAK_PROB = 0.01 +export const BUG_TRAIL_STEERING_WEIGHT = 5.0 + +// Cursor repulsion +export const BUG_CURSOR_REPULSION_RADIUS = 125 +export const BUG_CURSOR_REPULSION_STRENGTH = 100 + +export const BUG_MORANDI_COLORS = [ + '#A4B7C9', + '#8FA899', + '#D4B2AA', + '#DED0B6', + '#9BA88D', + '#C6B5A6', + '#8DA399', + '#B5A398', + '#A99D98', + '#E0CDB6', +] diff --git a/lib/pixel-office/bugs/pheromoneField.ts b/lib/pixel-office/bugs/pheromoneField.ts new file mode 100644 index 0000000..18e067b --- /dev/null +++ b/lib/pixel-office/bugs/pheromoneField.ts @@ -0,0 +1,106 @@ +export class BugPheromoneField { + private width: number + private height: number + private cellSize: number + private cols: number + private rows: number + private data: Float32Array + + constructor(width: number, height: number, cellSize: number) { + this.width = Math.max(1, width) + this.height = Math.max(1, height) + this.cellSize = Math.max(1, cellSize) + this.cols = Math.max(1, Math.ceil(this.width / this.cellSize)) + this.rows = Math.max(1, Math.ceil(this.height / this.cellSize)) + this.data = new Float32Array(this.cols * this.rows) + } + + resize(width: number, height: number): void { + const w = Math.max(1, width) + const h = Math.max(1, height) + const cols = Math.max(1, Math.ceil(w / this.cellSize)) + const rows = Math.max(1, Math.ceil(h / this.cellSize)) + if (cols === this.cols && rows === this.rows) { + this.width = w + this.height = h + return + } + this.width = w + this.height = h + this.cols = cols + this.rows = rows + this.data = new Float32Array(this.cols * this.rows) + } + + update(dt: number, evaporationPerSec: number): void { + if (dt <= 0) return + const keep = Math.max(0, 1 - evaporationPerSec * dt) + for (let i = 0; i < this.data.length; i++) { + this.data[i] *= keep + if (this.data[i] < 0.0001) this.data[i] = 0 + } + } + + deposit( + x: number, + y: number, + dirX: number, + dirY: number, + amount: number, + maxStrength: number, + ): void { + const cx = Math.floor(x / this.cellSize) + const cy = Math.floor(y / this.cellSize) + this.addToCell(cx, cy, amount, maxStrength) + + // Add a little forward deposition to create directional trails. + const fx = x + dirX * this.cellSize * 0.5 + const fy = y + dirY * this.cellSize * 0.5 + const fcx = Math.floor(fx / this.cellSize) + const fcy = Math.floor(fy / this.cellSize) + this.addToCell(fcx, fcy, amount * 0.6, maxStrength) + } + + sampleDirection(x: number, y: number, radius: number): { x: number; y: number; strength: number } { + const cx = Math.floor(x / this.cellSize) + const cy = Math.floor(y / this.cellSize) + const cr = Math.max(1, Math.ceil(radius / this.cellSize)) + + let fx = 0 + let fy = 0 + let strength = 0 + + for (let gx = cx - cr; gx <= cx + cr; gx++) { + for (let gy = cy - cr; gy <= cy + cr; gy++) { + const idx = this.index(gx, gy) + if (idx < 0) continue + const s = this.data[idx] + if (s <= 0) continue + const centerX = (gx + 0.5) * this.cellSize + const centerY = (gy + 0.5) * this.cellSize + const dx = centerX - x + const dy = centerY - y + const dist = Math.hypot(dx, dy) + if (dist > radius || dist <= 0.0001) continue + const w = s / (1 + dist * 0.1) + fx += (dx / dist) * w + fy += (dy / dist) * w + strength += s + } + } + + return { x: fx, y: fy, strength } + } + + private addToCell(cx: number, cy: number, value: number, maxStrength: number): void { + const idx = this.index(cx, cy) + if (idx < 0) return + this.data[idx] = Math.min(maxStrength, this.data[idx] + value) + } + + private index(cx: number, cy: number): number { + if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return -1 + return cy * this.cols + cx + } +} + diff --git a/lib/pixel-office/bugs/renderer.ts b/lib/pixel-office/bugs/renderer.ts new file mode 100644 index 0000000..f02c597 --- /dev/null +++ b/lib/pixel-office/bugs/renderer.ts @@ -0,0 +1,118 @@ +import type { BugEntity } from './types' +import { BUG_BODY_DIMENSIONS } from './bugSystem' + +function localToWorldForRender( + bug: BugEntity, + lx: number, + ly: number, +): { x: number; y: number } { + const h = bug.renderHeading + const c = Math.cos(h) + const s = Math.sin(h) + return { + x: bug.x + lx * c - ly * s, + y: bug.y + lx * s + ly * c, + } +} + +export function renderBugs( + ctx: CanvasRenderingContext2D, + bugs: BugEntity[], + offsetX: number, + offsetY: number, + zoom: number, +): void { + for (const bug of bugs) { + drawBugLegs(ctx, bug, offsetX, offsetY, zoom) + drawBugBody(ctx, bug, offsetX, offsetY, zoom) + } +} + +function drawBugLegs( + ctx: CanvasRenderingContext2D, + bug: BugEntity, + offsetX: number, + offsetY: number, + zoom: number, +): void { + ctx.save() + ctx.lineCap = 'round' + ctx.lineJoin = 'round' + const femurW = Math.max(0.5, 0.62 * bug.size * zoom) + const tibiaW = Math.max(0.35, 0.46 * bug.size * zoom) + const legColor = bug.color + + for (const leg of bug.legs) { + const anchor = localToWorldForRender( + bug, + leg.anchorLocalX * bug.size, + leg.anchorLocalY * bug.size, + ) + const aX = offsetX + anchor.x * zoom + const aY = offsetY + anchor.y * zoom + const kX = offsetX + leg.kneeX * zoom + const kY = offsetY + leg.kneeY * zoom + const fX = offsetX + leg.footX * zoom + const fY = offsetY + leg.footY * zoom + + ctx.strokeStyle = legColor + ctx.lineWidth = femurW + ctx.beginPath() + ctx.moveTo(aX, aY) + ctx.lineTo(kX, kY) + ctx.stroke() + + ctx.lineWidth = tibiaW + ctx.beginPath() + ctx.moveTo(kX, kY) + ctx.lineTo(fX, fY) + ctx.stroke() + } + ctx.restore() +} + +function drawBugBody( + ctx: CanvasRenderingContext2D, + bug: BugEntity, + offsetX: number, + offsetY: number, + zoom: number, +): void { + const bx = offsetX + bug.x * zoom + const by = offsetY + bug.y * zoom + const scale = bug.size * zoom + const headSize = BUG_BODY_DIMENSIONS.head * scale + const thoraxLength = BUG_BODY_DIMENSIONS.thoraxLength * scale + const thoraxWidth = BUG_BODY_DIMENSIONS.thoraxWidth * scale + const abdomenLength = BUG_BODY_DIMENSIONS.abdomenLength * scale + const abdomenWidth = BUG_BODY_DIMENSIONS.abdomenWidth * scale + const headRadius = headSize / 2 + const thoraxHalfLength = thoraxLength / 2 + const abdomenHalfLength = abdomenLength / 2 + const overlap = 0.8 + const headOffsetX = (thoraxHalfLength + headRadius) * overlap + const abdomenOffsetX = -(thoraxHalfLength + abdomenHalfLength) * overlap + + ctx.save() + ctx.translate(bx, by) + ctx.rotate(bug.renderHeading) + + ctx.fillStyle = bug.color + ellipse(ctx, headOffsetX, 0, headSize / 2, headSize / 2) + ellipse(ctx, 0, 0, thoraxLength / 2, thoraxWidth / 2) + ellipse(ctx, abdomenOffsetX, 0, abdomenLength / 2, abdomenWidth / 2) + + ctx.restore() +} + +function ellipse( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + rx: number, + ry: number, +): void { + ctx.beginPath() + ctx.ellipse(x, y, rx, ry, 0, 0, Math.PI * 2) + ctx.fill() +} diff --git a/lib/pixel-office/bugs/spatialGrid.ts b/lib/pixel-office/bugs/spatialGrid.ts new file mode 100644 index 0000000..fd6b63c --- /dev/null +++ b/lib/pixel-office/bugs/spatialGrid.ts @@ -0,0 +1,48 @@ +import type { BugEntity } from './types' + +export class BugSpatialGrid { + private readonly cellSize: number + private cells: Map = new Map() + + constructor(cellSize: number) { + this.cellSize = Math.max(1, cellSize) + } + + clear(): void { + this.cells.clear() + } + + add(bug: BugEntity): void { + const key = this.keyFor(bug.x, bug.y) + const bucket = this.cells.get(key) + if (bucket) { + bucket.push(bug) + } else { + this.cells.set(key, [bug]) + } + } + + query(x: number, y: number, radius: number): BugEntity[] { + const r = Math.max(0, radius) + const minCx = Math.floor((x - r) / this.cellSize) + const maxCx = Math.floor((x + r) / this.cellSize) + const minCy = Math.floor((y - r) / this.cellSize) + const maxCy = Math.floor((y + r) / this.cellSize) + const out: BugEntity[] = [] + for (let cx = minCx; cx <= maxCx; cx++) { + for (let cy = minCy; cy <= maxCy; cy++) { + const bucket = this.cells.get(`${cx},${cy}`) + if (!bucket) continue + for (const bug of bucket) out.push(bug) + } + } + return out + } + + private keyFor(x: number, y: number): string { + const cx = Math.floor(x / this.cellSize) + const cy = Math.floor(y / this.cellSize) + return `${cx},${cy}` + } +} + diff --git a/lib/pixel-office/bugs/types.ts b/lib/pixel-office/bugs/types.ts new file mode 100644 index 0000000..f22091b --- /dev/null +++ b/lib/pixel-office/bugs/types.ts @@ -0,0 +1,45 @@ +export type BugBehaviorType = 'social' | 'loner' | 'edgeDweller' + +export interface BugLeg { + index: number + group: 0 | 1 + sideSign: -1 | 1 + isRightSide: boolean + anchorLocalX: number + anchorLocalY: number + restLocalX: number + restLocalY: number + footX: number + footY: number + kneeX: number + kneeY: number + stepping: boolean + stepT: number + stepDuration: number + stepFromX: number + stepFromY: number + stepToX: number + stepToY: number +} + +export interface BugEntity { + id: number + x: number + y: number + vx: number + vy: number + heading: number + renderHeading: number + speed: number + size: number + wanderTimer: number + wanderTargetHeading: number + behaviorType: BugBehaviorType + color: string + activeLegGroup: 0 | 1 + gaitTimer: number + legUpdateTimer: number + followingTrail: boolean + isCarrier: boolean + legs: BugLeg[] +} diff --git a/lib/pixel-office/engine/officeState.ts b/lib/pixel-office/engine/officeState.ts index 2031442..1316eb1 100644 --- a/lib/pixel-office/engine/officeState.ts +++ b/lib/pixel-office/engine/officeState.ts @@ -28,6 +28,9 @@ import { } from '../layout/layoutSerializer' import type { InteractionPoint } from '../layout/layoutSerializer' import { getCatalogEntry, getOnStateType } from '../layout/furnitureCatalog' +import { BugSystem } from '../bugs/bugSystem' +import { BUG_DEFAULT_COUNT } from '../bugs/config' +import type { BugEntity } from '../bugs/types' const CODE_SNIPPET_LIFETIME = 2.5 // seconds const CODE_SNIPPET_SPAWN_RATE = 0.6 // per second @@ -107,6 +110,9 @@ export class OfficeState { private static CAT_ID = -9999 private static LOBSTER_ID = -9998 private static HUNTER_LOBSTER_ID = -9997 + private bugSystem: BugSystem + private bugWorldWidth: number + private bugWorldHeight: number constructor(layout?: OfficeLayout) { this.layout = layout || createDefaultLayout() @@ -120,6 +126,9 @@ export class OfficeState { this.spawnCat() this.spawnLobster() this.spawnHunterLobster() + this.bugWorldWidth = this.layout.cols * TILE_SIZE + this.bugWorldHeight = this.layout.rows * TILE_SIZE + this.bugSystem = new BugSystem(this.bugWorldWidth, this.bugWorldHeight, BUG_DEFAULT_COUNT) } /** Rebuild all derived state from a new layout. Reassigns existing characters. @@ -898,6 +907,7 @@ export class OfficeState { } update(dt: number): void { + this.bugSystem.update(dt, this.bugWorldWidth, this.bugWorldHeight) const toDelete: number[] = [] const firstIdleHumanoid = this.getFirstIdleHumanoid() for (const ch of this.characters.values()) { @@ -1000,6 +1010,35 @@ export class OfficeState { return Array.from(this.characters.values()) } + getBugs(): BugEntity[] { + return this.bugSystem.getBugs() + } + + isBugEnabled(): boolean { + return this.bugSystem.isEnabled() + } + + setBugEnabled(enabled: boolean): void { + this.bugSystem.setEnabled(enabled) + } + + getBugTargetCount(): number { + return this.bugSystem.getTargetCount() + } + + setBugTargetCount(count: number): void { + this.bugSystem.setTargetCount(count, this.bugWorldWidth, this.bugWorldHeight) + } + + setBugWorldSize(width: number, height: number): void { + this.bugWorldWidth = Math.max(1, width) + this.bugWorldHeight = Math.max(1, height) + } + + setBugCursor(x: number, y: number, active: boolean): void { + this.bugSystem.setCursor(x, y, active) + } + /** Get character at pixel position (for hit testing). Returns id or null. */ getCharacterAt(worldX: number, worldY: number): number | null { const chars = this.getCharacters().sort((a, b) => b.y - a.y) diff --git a/lib/pixel-office/engine/renderer.ts b/lib/pixel-office/engine/renderer.ts index 0197b03..744458d 100644 --- a/lib/pixel-office/engine/renderer.ts +++ b/lib/pixel-office/engine/renderer.ts @@ -7,6 +7,8 @@ import { getCharacterSprite } from './characters' import { renderMatrixEffect } from './matrixEffect' import { getColorizedFloorSprite, hasFloorSprites, WALL_COLOR } from '../floorTiles' import { hasWallSprites, getWallInstances, wallColorToHex } from '../wallTiles' +import type { BugEntity } from '../bugs/types' +import { renderBugs } from '../bugs/renderer' import { CHARACTER_SITTING_OFFSET_PX, CHARACTER_Z_SORT_OFFSET, @@ -902,6 +904,7 @@ export function renderFrame( tileColors?: Array, layoutCols?: number, layoutRows?: number, + bugs?: BugEntity[], contributions?: ContributionData, photograph?: HTMLImageElement, ): { offsetX: number; offsetY: number } { @@ -921,6 +924,10 @@ export function renderFrame( // Draw tiles (floor + wall base color) renderTileGrid(ctx, tileMap, offsetX, offsetY, zoom, tileColors, layoutCols) + if (bugs && bugs.length > 0) { + renderBugs(ctx, bugs, offsetX, offsetY, zoom) + } + // Seat indicators (below furniture/characters, on top of floor) if (selection) { renderSeatIndicators(ctx, selection.seats, selection.characters, selection.selectedAgentId, selection.hoveredTile, offsetX, offsetY, zoom)