mirror of
https://github.com/xmanrui/OpenClaw-bot-review.git
synced 2026-07-27 22:25:52 +00:00
feat(pixel-office): global bug controls and ant-like logo carry behavior
This commit is contained in:
@@ -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]" />;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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',
|
||||
]
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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,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)
|
||||
|
||||
Reference in New Issue
Block a user