feat(pixel-office): global bug controls and ant-like logo carry behavior

This commit is contained in:
xmanrui
2026-03-01 03:33:18 +08:00
parent ee04753df1
commit a106d8002a
12 changed files with 1739 additions and 4 deletions
+124
View File
@@ -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<HTMLCanvasElement>(null);
const systemRef = useRef<BugSystem | null>(null);
const enabledRef = useRef(false);
const countRef = useRef(5);
const mouseRef = useRef({ x: 0, y: 0, active: false });
const rafRef = useRef<number | null>(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 <canvas ref={canvasRef} className="fixed inset-0 pointer-events-none z-[60]" />;
}
+2
View File
@@ -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 })
<body>
<Providers>
<AlertMonitor />
<GlobalBugsOverlay />
<div className="flex min-h-screen">
<Sidebar />
<main className="flex-1 overflow-auto">{children}</main>
+10 -1
View File
@@ -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
+139 -3
View File
@@ -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 ? (
<div className="flex flex-col items-center gap-2">
<Link href="/">
<span className="text-3xl">🦞</span>
<span
className="relative inline-block transition-opacity duration-300"
style={{
fontSize: "3.375rem",
lineHeight: 1,
transform: `translate(${logoCarry.dx}px, ${logoCarry.dy}px) rotate(${logoCarry.angle}rad)`,
opacity: logoCarry.hidden ? 0 : 1,
}}
>
🦞
</span>
</Link>
<button
onClick={() => setCollapsed(false)}
@@ -61,7 +137,17 @@ export function Sidebar() {
<div>
<div className="flex items-center justify-between">
<Link href="/" className="flex items-center gap-2">
<span className="text-3xl">🦞</span>
<span
className="relative inline-block transition-opacity duration-300"
style={{
fontSize: "3.375rem",
lineHeight: 1,
transform: `translate(${logoCarry.dx}px, ${logoCarry.dy}px) rotate(${logoCarry.angle}rad)`,
opacity: logoCarry.hidden ? 0 : 1,
}}
>
🦞
</span>
<div>
<div className="text-sm font-bold text-[var(--text)] tracking-wide">OPENCLAW</div>
<div className="text-[10px] text-[var(--text-muted)] tracking-wider">BOT DASHBOARD</div>
@@ -121,6 +207,56 @@ export function Sidebar() {
</div>
</div>
))}
{!collapsed && isPixelOfficePage && (
<div className="rounded-xl border border-[var(--border)] bg-[var(--card)]/65 p-1">
<button
onClick={() => setExperimentOpen((v) => !v)}
className={`w-full flex items-center justify-between rounded-lg px-3 py-2 transition-colors ${
experimentOpen
? "bg-[var(--accent)]/12 text-[var(--accent)] border border-[var(--accent)]/35"
: "bg-[var(--bg)] text-[var(--text)] border border-[var(--border)] hover:bg-[var(--accent)]/8"
}`}
>
<span className="flex items-center gap-2">
<span className="text-sm">🧪</span>
<span className="text-sm font-semibold tracking-wide"></span>
</span>
<span
className={`inline-flex items-center justify-center text-base leading-none transition-transform ${
experimentOpen ? "text-[var(--accent)] rotate-180" : "text-[var(--text-muted)]"
}`}
>
</span>
</button>
{experimentOpen && (
<div className="mt-2 space-y-2 rounded-lg border border-[var(--border)] bg-[var(--card)] p-2">
<button
onClick={toggleBugs}
className={`w-full px-3 py-1.5 text-xs rounded-lg border transition-colors ${
bugsEnabled
? "bg-[var(--accent)]/10 border-[var(--accent)]/30 text-[var(--accent)]"
: "bg-[var(--card)] border-[var(--border)] text-[var(--text-muted)]"
}`}
>
{bugsEnabled ? "🐛 Bugs On" : "🐛 Bugs Off"}
</button>
<label className="flex items-center justify-between gap-2 px-2 py-1.5 text-xs rounded-lg border bg-[var(--card)] border-[var(--border)] text-[var(--text-muted)]">
<span>Count {bugsCount}</span>
<input
type="range"
min={0}
max={BUGS_MAX}
step={1}
value={bugsCount}
onChange={(e) => onBugCountChange(Number(e.target.value))}
className="w-24 accent-[var(--accent)]"
/>
</label>
</div>
)}
</div>
)}
</div>
</nav>
</aside>
File diff suppressed because it is too large Load Diff
+74
View File
@@ -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',
]
+106
View File
@@ -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
}
}
+118
View File
@@ -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()
}
+48
View File
@@ -0,0 +1,48 @@
import type { BugEntity } from './types'
export class BugSpatialGrid {
private readonly cellSize: number
private cells: Map<string, BugEntity[]> = 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}`
}
}
+45
View File
@@ -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[]
}
+39
View File
@@ -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)
+7
View File
@@ -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<FloorColor | null>,
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)