Move operator surface into aquarium sim
This commit is contained in:
parent
bd2ab602e0
commit
e0e98e225a
4 changed files with 769 additions and 154 deletions
|
|
@ -8,6 +8,7 @@ const artifactDir = resolve(root, ".epiphany-gui");
|
|||
const desktopScreenshotPath = resolve(artifactDir, "operator-console-smoke-desktop.png");
|
||||
const mobileScreenshotPath = resolve(artifactDir, "operator-console-smoke-mobile.png");
|
||||
const url = "http://127.0.0.1:1420";
|
||||
const fluidStorageKey = "epiphany:aquarium-fluid-params:v1";
|
||||
|
||||
await mkdir(artifactDir, { recursive: true });
|
||||
|
||||
|
|
@ -28,14 +29,15 @@ try {
|
|||
channel: "msedge",
|
||||
headless: true,
|
||||
});
|
||||
await smokeViewport(browser, { width: 1366, height: 900 }, desktopScreenshotPath);
|
||||
await smokeViewport(browser, { width: 390, height: 844 }, mobileScreenshotPath);
|
||||
const desktop = await smokeViewport(browser, { width: 1366, height: 900 }, desktopScreenshotPath, true);
|
||||
const mobile = await smokeViewport(browser, { width: 390, height: 844 }, mobileScreenshotPath, false);
|
||||
await browser.close();
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
screenshots: [desktopScreenshotPath, mobileScreenshotPath],
|
||||
probes: { desktop, mobile },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
|
|
@ -45,22 +47,74 @@ try {
|
|||
await server.close();
|
||||
}
|
||||
|
||||
async function smokeViewport(browser, viewport, screenshotPath) {
|
||||
async function smokeViewport(browser, viewport, screenshotPath, exerciseFluidPanel) {
|
||||
const page = await browser.newPage({ viewport });
|
||||
await page.goto(url, { waitUntil: "networkidle" });
|
||||
await page.getByRole("heading", { name: "Operator Console" }).waitFor();
|
||||
await page.locator("h1").filter({ hasText: "Operator Console" }).waitFor({ state: "attached" });
|
||||
await page.locator(".immersiveShell").waitFor();
|
||||
await page.getByRole("button", { name: "Self prepareCheckpoint" }).waitFor();
|
||||
await page.locator(".agentSmokeCanvas").waitFor();
|
||||
await page.waitForTimeout(350);
|
||||
const canvasProbe = await page.locator(".agentSmokeCanvas").evaluate((canvas) => {
|
||||
await page.locator(".agentCrispCanvas").waitFor();
|
||||
await page.waitForTimeout(700);
|
||||
|
||||
const smokeProbe = await probeCanvas(page, ".agentSmokeCanvas");
|
||||
const crispProbe = await probeCanvas(page, ".agentCrispCanvas");
|
||||
if (!smokeProbe.nonBlank) {
|
||||
throw new Error(`agent smoke canvas did not render: ${smokeProbe.reason}`);
|
||||
}
|
||||
if (!crispProbe.nonBlank) {
|
||||
throw new Error(`agent crisp canvas did not render: ${crispProbe.reason}`);
|
||||
}
|
||||
|
||||
const hiddenSurface = await page.evaluate(() => {
|
||||
return [".immersiveTopbar", ".deckRail", ".diegeticPanel", ".hudToastStack"].every((selector) => {
|
||||
const element = document.querySelector(selector);
|
||||
if (!element) return false;
|
||||
const style = window.getComputedStyle(element);
|
||||
return style.opacity === "0" && style.pointerEvents === "none";
|
||||
});
|
||||
});
|
||||
if (!hiddenSurface) {
|
||||
throw new Error("static operator overlays are still visible or interactive");
|
||||
}
|
||||
|
||||
let persistedParams = null;
|
||||
if (exerciseFluidPanel) {
|
||||
await page.evaluate((key) => window.localStorage.removeItem(key), fluidStorageKey);
|
||||
await page.reload({ waitUntil: "networkidle" });
|
||||
await page.locator(".agentSmokeCanvas").waitFor();
|
||||
await page.waitForTimeout(700);
|
||||
await page.mouse.click(viewport.width - 48, viewport.height - 48);
|
||||
await page.waitForTimeout(120);
|
||||
await page.mouse.move(viewport.width - 360, 276);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(viewport.width - 118, 276, { steps: 8 });
|
||||
await page.mouse.up();
|
||||
persistedParams = await page.evaluate((key) => window.localStorage.getItem(key), fluidStorageKey);
|
||||
if (!persistedParams || !persistedParams.includes("timeScale")) {
|
||||
throw new Error("fluid parameter panel did not persist changed parameters");
|
||||
}
|
||||
}
|
||||
|
||||
await page.screenshot({ path: screenshotPath, fullPage: true });
|
||||
const result = await page.evaluate(() => ({
|
||||
horizontalOverflow: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
|
||||
}));
|
||||
await page.close();
|
||||
if (result.horizontalOverflow) {
|
||||
throw new Error(`visual smoke found horizontal overflow at ${viewport.width}x${viewport.height}`);
|
||||
}
|
||||
return { smokeProbe, crispProbe, persistedParams };
|
||||
}
|
||||
|
||||
async function probeCanvas(page, selector) {
|
||||
return page.locator(selector).evaluate((canvas) => {
|
||||
if (!(canvas instanceof HTMLCanvasElement) || canvas.width === 0 || canvas.height === 0) {
|
||||
return { nonBlank: false, reason: "canvas has no drawable dimensions" };
|
||||
}
|
||||
const gl = canvas.getContext("webgl2");
|
||||
if (gl) {
|
||||
const width = Math.min(6, canvas.width);
|
||||
const height = Math.min(6, canvas.height);
|
||||
const width = Math.min(12, canvas.width);
|
||||
const height = Math.min(12, canvas.height);
|
||||
const pixels = new Uint8Array(width * height * 4);
|
||||
gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
|
||||
return {
|
||||
|
|
@ -70,125 +124,16 @@ async function smokeViewport(browser, viewport, screenshotPath) {
|
|||
}
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return { nonBlank: false, reason: "no readable canvas context" };
|
||||
const width = Math.min(6, canvas.width);
|
||||
const height = Math.min(6, canvas.height);
|
||||
const pixels = context.getImageData(0, 0, width, height).data;
|
||||
const width = Math.min(64, canvas.width);
|
||||
const height = Math.min(64, canvas.height);
|
||||
const centerX = Math.max(0, Math.floor(canvas.width / 2 - width / 2));
|
||||
const centerY = Math.max(0, Math.floor(canvas.height / 2 - height / 2));
|
||||
const pixels = context.getImageData(centerX, centerY, width, height).data;
|
||||
return {
|
||||
nonBlank: Array.from(pixels).some((value) => value !== 0),
|
||||
reason: "2d sample",
|
||||
};
|
||||
});
|
||||
if (!canvasProbe.nonBlank) {
|
||||
throw new Error(`agent smoke canvas did not render: ${canvasProbe.reason}`);
|
||||
}
|
||||
await page.getByRole("button", { name: "Command" }).waitFor();
|
||||
await page.getByRole("button", { name: "State" }).waitFor();
|
||||
await page.getByRole("button", { name: "Agents" }).waitFor();
|
||||
await page.getByRole("button", { name: "Artifacts" }).waitFor();
|
||||
await page.getByRole("button", { name: "Prepare Checkpoint" }).waitFor();
|
||||
await page.getByRole("button", { name: "Inspect Rider" }).waitFor();
|
||||
await page.getByRole("button", { name: "Adopt Draft" }).waitFor();
|
||||
await page.getByRole("button", { name: "Launch Imagination" }).waitFor();
|
||||
await page.getByRole("button", { name: "Read Imagination" }).waitFor();
|
||||
await page.getByRole("button", { name: "Launch Modeling" }).waitFor();
|
||||
await page.getByRole("button", { name: "Read Modeling" }).waitFor();
|
||||
await page.getByRole("button", { name: "Launch Verification" }).waitFor();
|
||||
await page.getByRole("button", { name: "Read Verification" }).waitFor();
|
||||
await page.getByRole("button", { name: "Launch Reorient" }).waitFor();
|
||||
await page.getByRole("button", { name: "Read Reorient" }).waitFor();
|
||||
await page.getByRole("button", { name: "Accept Reorient" }).waitFor();
|
||||
await page.getByRole("button", { name: "State" }).click();
|
||||
await page.getByRole("button", { name: "environment" }).waitFor();
|
||||
await page.getByText("Unity Editor").waitFor();
|
||||
await page.getByRole("heading", { name: "Rider", exact: true }).waitFor();
|
||||
await page.getByText("Aetheria.sln").waitFor();
|
||||
await page.getByRole("button", { name: "planning" }).click();
|
||||
await page.getByRole("heading", { name: "Build the planning dashboard slice", exact: true }).waitFor();
|
||||
await page.getByRole("button", { name: "graph" }).click();
|
||||
await page.getByText("Epiphany Typed Graph").waitFor();
|
||||
await page.getByRole("button", { name: "Agents" }).click();
|
||||
await page.getByRole("button", { name: "lanes" }).waitFor();
|
||||
await page.getByRole("heading", { name: "Implementation", exact: true }).waitFor();
|
||||
await page.getByRole("button", { name: "findings" }).click();
|
||||
await page.getByRole("heading", { name: "Imagination / Planning", exact: true }).waitFor();
|
||||
await page.getByRole("button", { name: "jobs" }).click();
|
||||
await page.getByText("retrieval-index").waitFor();
|
||||
await page.getByRole("button", { name: "Artifacts" }).click();
|
||||
await page.getByText("runtime/unity-inspect-sample").waitFor();
|
||||
await page.getByRole("button", { name: "Command" }).click();
|
||||
if (viewport.width >= 900) {
|
||||
await page.getByRole("button", { name: "Status Snapshot" }).click();
|
||||
await page.getByText("statusSnapshot sample completed.").waitFor();
|
||||
await page.getByRole("button", { name: "Coordinator Plan" }).click();
|
||||
await page.getByText("coordinatorPlan sample completed.").waitFor();
|
||||
await page.getByRole("button", { name: "Inspect Rider" }).click();
|
||||
await page.getByText("inspectRider sample completed.").waitFor();
|
||||
await page.getByRole("button", { name: "Prepare Checkpoint" }).click();
|
||||
await page.getByText("prepareCheckpoint sample completed.").waitFor();
|
||||
await page.getByRole("button", { name: "Read Imagination" }).click();
|
||||
await page.getByText("readImaginationResult sample completed.").waitFor();
|
||||
await page.getByRole("button", { name: "Read Modeling" }).click();
|
||||
await page.getByText("readModelingResult sample completed.").waitFor();
|
||||
await page.getByRole("button", { name: "Read Reorient" }).click();
|
||||
await page.getByText("readReorientResult sample completed.").waitFor();
|
||||
}
|
||||
await page.screenshot({ path: screenshotPath, fullPage: true });
|
||||
|
||||
const result = await page.evaluate(() => {
|
||||
const elements = Array.from(document.querySelectorAll("h1, h2, h3, p, dd, code, button, input, .pill"));
|
||||
const overlaps = [];
|
||||
function isPaintedAtSample(element, rect) {
|
||||
const style = window.getComputedStyle(element);
|
||||
if (style.visibility === "hidden" || style.display === "none" || Number(style.opacity) === 0) {
|
||||
return false;
|
||||
}
|
||||
const points = [
|
||||
[rect.left + rect.width / 2, rect.top + rect.height / 2],
|
||||
[rect.left + Math.min(8, rect.width / 2), rect.top + rect.height / 2],
|
||||
[rect.right - Math.min(8, rect.width / 2), rect.top + rect.height / 2],
|
||||
[rect.left + rect.width / 2, rect.top + Math.min(8, rect.height / 2)],
|
||||
[rect.left + rect.width / 2, rect.bottom - Math.min(8, rect.height / 2)],
|
||||
];
|
||||
return points.some(([x, y]) => {
|
||||
if (x < 0 || y < 0 || x > window.innerWidth || y > window.innerHeight) return false;
|
||||
const hit = document.elementFromPoint(x, y);
|
||||
return hit === element || Boolean(hit && element.contains(hit));
|
||||
});
|
||||
}
|
||||
for (let index = 0; index < elements.length; index += 1) {
|
||||
const left = elements[index];
|
||||
const a = elements[index].getBoundingClientRect();
|
||||
if (a.width === 0 || a.height === 0) continue;
|
||||
if (!isPaintedAtSample(left, a)) continue;
|
||||
for (let other = index + 1; other < elements.length; other += 1) {
|
||||
const right = elements[other];
|
||||
if (left.contains(right) || right.contains(left)) continue;
|
||||
const b = elements[other].getBoundingClientRect();
|
||||
if (b.width === 0 || b.height === 0) continue;
|
||||
if (!isPaintedAtSample(right, b)) continue;
|
||||
const x = Math.min(a.right, b.right) - Math.max(a.left, b.left);
|
||||
const y = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
|
||||
if (x > 2 && y > 2) {
|
||||
overlaps.push({
|
||||
left: left.textContent?.trim() || left.getAttribute("placeholder") || left.tagName,
|
||||
right: right.textContent?.trim() || right.getAttribute("placeholder") || right.tagName,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
overlaps,
|
||||
horizontalOverflow: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
|
||||
};
|
||||
});
|
||||
|
||||
await page.close();
|
||||
if (result.horizontalOverflow) {
|
||||
throw new Error(`visual smoke found horizontal overflow at ${viewport.width}x${viewport.height}`);
|
||||
}
|
||||
if (result.overlaps.length > 0) {
|
||||
throw new Error(`visual smoke found overlapping text/control boxes: ${JSON.stringify(result.overlaps)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForServer(target) {
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import { createAquariumRenderer } from "./aquariumFluid";
|
|||
import { loadOperatorSnapshot, runOperatorAction } from "./operatorApi";
|
||||
import type { ArtifactBundle, OperatorAction, OperatorActionResult, OperatorSnapshot, StatusRequest } from "./types";
|
||||
import type { EpiphanyCodeRef, EpiphanyGraphsState } from "@epiphanygraph/epiphany-graph-viewer";
|
||||
import type { AquariumOptionFrame, AquariumRenderer } from "./aquariumFluid";
|
||||
import type { AquariumOptionFrame, AquariumRenderer, AquariumUiFrame } from "./aquariumFluid";
|
||||
|
||||
const roleOrder = ["implementation", "imagination", "research", "eyes", "modeling", "verification", "reorientation"];
|
||||
const constellationSpecs = [
|
||||
|
|
@ -392,6 +392,10 @@ function text(value: unknown, fallback = "none"): string {
|
|||
return fallback;
|
||||
}
|
||||
|
||||
function displayLabel(value: unknown, fallback = "none"): string {
|
||||
return text(value, fallback).replace(/([a-z])([A-Z])/g, "$1 $2");
|
||||
}
|
||||
|
||||
function listText(value: unknown, fallback = "none"): string {
|
||||
return Array.isArray(value) && value.length > 0 ? value.map(String).join(", ") : fallback;
|
||||
}
|
||||
|
|
@ -726,6 +730,107 @@ export function App() {
|
|||
</button>
|
||||
);
|
||||
});
|
||||
const aquariumPanelLines = (() => {
|
||||
if (activeDeck === "command" && activeSubdeck === "run") {
|
||||
return [
|
||||
text(actionResult?.summary, "Choose a bounded operation; review gates stay explicit."),
|
||||
text(actionResult?.artifactPath, "No fresh artifact path."),
|
||||
];
|
||||
}
|
||||
if (activeDeck === "command" && activeSubdeck === "connection") {
|
||||
return [
|
||||
`Thread: ${text(status?.threadId)}`,
|
||||
`State: ${text(scene.stateStatus)} rev ${text(scene.revision)}`,
|
||||
`Repo: ${text(snapshot?.repoRoot)}`,
|
||||
`Draft: ${text(request.planningDraftId)}`,
|
||||
];
|
||||
}
|
||||
if (activeDeck === "command" && activeSubdeck === "signals") {
|
||||
return [
|
||||
`Action: ${text(coordinator.action ?? crrc.action, "unknown")}`,
|
||||
`Target: ${text(coordinator.targetRole ?? crrc.recommendedSceneAction)}`,
|
||||
`Review: ${text(coordinator.requiresReview)}`,
|
||||
`Pressure: ${text(pressure.level)}`,
|
||||
`Reorient: ${text(reorient.action)} / ${text(reorient.nextAction)}`,
|
||||
];
|
||||
}
|
||||
if (activeDeck === "state" && activeSubdeck === "environment") {
|
||||
return [
|
||||
`Unity: ${unityBridgeReady ? "bridge ready" : text(latestRuntimeAudit?.status, "unknown")}`,
|
||||
`Editor: ${text(latestRuntimeAudit?.editorPath, "missing")}`,
|
||||
`Rider: ${text(latestRiderAudit?.status, "unknown")}`,
|
||||
`Solution: ${text(latestRiderAudit?.solutionPath)}`,
|
||||
`Changed files: ${riderChangedFiles.length}`,
|
||||
];
|
||||
}
|
||||
if (activeDeck === "state" && activeSubdeck === "planning") {
|
||||
return [
|
||||
`Captures: ${countText(planningSummary?.captureCount)} / pending ${countText(planningSummary?.pendingCaptureCount)}`,
|
||||
`Backlog: ${countText(planningSummary?.backlogItemCount)} / ready ${countText(planningSummary?.readyBacklogItemCount)}`,
|
||||
`Drafts: ${countText(planningSummary?.objectiveDraftCount)}`,
|
||||
text(selectedDraft?.title, "No draft objective selected."),
|
||||
];
|
||||
}
|
||||
if (activeDeck === "state" && activeSubdeck === "graph") {
|
||||
return [
|
||||
`Architecture: ${graphState.architecture.nodes.length} nodes / ${graphState.architecture.edges.length} edges`,
|
||||
`Dataflow: ${graphState.dataflow.nodes.length} nodes / ${graphState.dataflow.edges.length} edges`,
|
||||
`Links: ${graphState.links.length}`,
|
||||
`Issues: ${graphIssues.length}`,
|
||||
];
|
||||
}
|
||||
if (activeDeck === "agents" && activeSubdeck === "lanes") {
|
||||
return roles.slice(0, 8).map((role) => `${text(role.title)}: ${text(role.status)} / ${text(role.note)}`);
|
||||
}
|
||||
if (activeDeck === "agents" && activeSubdeck === "findings") {
|
||||
return [
|
||||
`Imagination: ${text(roleResults.imagination?.status)}`,
|
||||
`Modeling: ${text(roleResults.modeling?.status)}`,
|
||||
`Verification: ${text(roleResults.verification?.status)}`,
|
||||
`Reorient: ${text(reorientResult?.status)}`,
|
||||
];
|
||||
}
|
||||
if (activeDeck === "agents" && activeSubdeck === "jobs") {
|
||||
return jobs.length ? jobs.slice(0, 8).map((job) => `${text(job.id)}: ${text(job.status)} / ${text(job.kind)}`) : ["No jobs loaded."];
|
||||
}
|
||||
return (snapshot?.artifacts ?? []).length
|
||||
? (snapshot?.artifacts ?? []).slice(0, 8).map((artifact) => `${artifact.name}: ${artifact.files.length} files`)
|
||||
: ["No dogfood artifact bundles found."];
|
||||
})();
|
||||
const aquariumUi: AquariumUiFrame = {
|
||||
eyebrow: "Epiphany MVP",
|
||||
title: "Operator Console",
|
||||
reason: text(coordinator.reason ?? crrc.reason, "No recommendation loaded yet."),
|
||||
activeDeckLabel: activeDeckTitle,
|
||||
activeSubdeck,
|
||||
statuses: [
|
||||
{ label: displayLabel(coordinator.action ?? crrc.action, "unknown"), tone: statusClass(coordinator.action ?? crrc.action) },
|
||||
{ label: `pressure ${text(pressure.level, "unknown")}`, tone: statusClass(pressure.level) },
|
||||
{ label: `continuity ${text(reorient.action, "unknown")}`, tone: statusClass(reorient.action) },
|
||||
],
|
||||
deckButtons: (Object.keys(deckSubmenus) as DeckId[]).map((deck) => ({
|
||||
key: `ui:deck:${deck}`,
|
||||
label: deckLabels[deck],
|
||||
tone: activeDeck === deck ? "warn" : "neutral",
|
||||
})),
|
||||
subdeckButtons: deckSubmenus[activeDeck].map((subdeck) => ({
|
||||
key: `ui:subdeck:${activeDeck}:${subdeck}`,
|
||||
label: subdeck,
|
||||
tone: activeSubdeck === subdeck ? "warn" : "neutral",
|
||||
})),
|
||||
actionButtons:
|
||||
activeDeck === "command" && activeSubdeck === "run"
|
||||
? actionButtons.map((button) => ({
|
||||
key: `ui:action:${button.action}`,
|
||||
label: runningAction === button.action ? button.runningLabel : button.label,
|
||||
disabled: actionBlocked(button.action),
|
||||
tone: actionBlocked(button.action) ? "neutral" : "ok",
|
||||
}))
|
||||
: [],
|
||||
panelTitle: `${activeDeckTitle} / ${activeSubdeck}`,
|
||||
panelLines: aquariumPanelLines,
|
||||
alert: error ?? (latestRuntimeAudit ? `Unity: ${text(latestRuntimeAudit.projectVersion)} is ${text(latestRuntimeAudit.status)}.` : undefined),
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (objectiveDrafts.length === 0) return;
|
||||
|
|
@ -755,6 +860,7 @@ export function App() {
|
|||
variant="fullscreen"
|
||||
activeDeck={activeDeck}
|
||||
activeSubdeck={activeSubdeck}
|
||||
ui={aquariumUi}
|
||||
onAgentOption={handleAquariumOption}
|
||||
isActionBlocked={actionBlocked}
|
||||
/>
|
||||
|
|
@ -1198,6 +1304,7 @@ function AgentConstellation({
|
|||
variant = "band",
|
||||
activeDeck,
|
||||
activeSubdeck,
|
||||
ui,
|
||||
onAgentOption,
|
||||
isActionBlocked,
|
||||
}: {
|
||||
|
|
@ -1212,12 +1319,14 @@ function AgentConstellation({
|
|||
variant?: "band" | "fullscreen";
|
||||
activeDeck?: DeckId;
|
||||
activeSubdeck?: string;
|
||||
ui?: AquariumUiFrame;
|
||||
onAgentOption?: (option: AquariumOption) => void;
|
||||
isActionBlocked?: (action: OperatorAction) => boolean;
|
||||
}) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const crispCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const optionByKeyRef = useRef(new globalThis.Map<string, AquariumOption>());
|
||||
const uiOptionByKeyRef = useRef(new globalThis.Map<string, AquariumOption>());
|
||||
const rendererRef = useRef<AquariumRenderer | null>(null);
|
||||
const [selectedAgentId, setSelectedAgentId] = useState("coordinator");
|
||||
const agents = useMemo<ProjectedAgent[]>(() => {
|
||||
|
|
@ -1296,6 +1405,29 @@ function AgentConstellation({
|
|||
return framedAgents;
|
||||
}, [agents, isActionBlocked]);
|
||||
|
||||
useEffect(() => {
|
||||
const uiOptions = new globalThis.Map<string, AquariumOption>();
|
||||
for (const button of ui?.deckButtons ?? []) {
|
||||
const deck = button.key.split(":")[2] as DeckId | undefined;
|
||||
if (deck && deck in deckLabels) {
|
||||
uiOptions.set(button.key, { label: button.label, deck });
|
||||
}
|
||||
}
|
||||
for (const button of ui?.subdeckButtons ?? []) {
|
||||
const [, , deck, subdeck] = button.key.split(":");
|
||||
if (deck && subdeck && deck in deckLabels) {
|
||||
uiOptions.set(button.key, { label: button.label, deck: deck as DeckId, subdeck });
|
||||
}
|
||||
}
|
||||
for (const button of ui?.actionButtons ?? []) {
|
||||
const action = button.key.split(":")[2] as OperatorAction | undefined;
|
||||
if (action) {
|
||||
uiOptions.set(button.key, { label: button.label, action });
|
||||
}
|
||||
}
|
||||
uiOptionByKeyRef.current = uiOptions;
|
||||
}, [ui]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const crispCanvas = crispCanvasRef.current;
|
||||
|
|
@ -1313,22 +1445,36 @@ function AgentConstellation({
|
|||
activeLabel: activeDeck ? `${deckLabels[activeDeck]} / ${activeSubdeck ?? ""}` : undefined,
|
||||
agents: aquariumAgents,
|
||||
selectedAgentId,
|
||||
ui,
|
||||
variant,
|
||||
});
|
||||
}, [activeDeck, activeSubdeck, aquariumAgents, selectedAgentId, variant]);
|
||||
}, [activeDeck, activeSubdeck, aquariumAgents, selectedAgentId, ui, variant]);
|
||||
|
||||
function handlePointerMove(event: React.PointerEvent<HTMLCanvasElement>) {
|
||||
rendererRef.current?.setPointerClient(event.clientX, event.clientY);
|
||||
}
|
||||
|
||||
function handlePointerDown(event: React.PointerEvent<HTMLCanvasElement>) {
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
rendererRef.current?.pointerDownClient(event.clientX, event.clientY);
|
||||
}
|
||||
|
||||
function handlePointerUp(event: React.PointerEvent<HTMLCanvasElement>) {
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
rendererRef.current?.pointerUp();
|
||||
}
|
||||
|
||||
function handlePointerLeave() {
|
||||
rendererRef.current?.clearPointer();
|
||||
rendererRef.current?.pointerUp();
|
||||
}
|
||||
|
||||
function handleCanvasClick() {
|
||||
const optionKey = rendererRef.current?.pickOption();
|
||||
if (optionKey) {
|
||||
const option = optionByKeyRef.current.get(optionKey);
|
||||
const option = optionByKeyRef.current.get(optionKey) ?? uiOptionByKeyRef.current.get(optionKey);
|
||||
if (option) {
|
||||
onAgentOption?.(option);
|
||||
}
|
||||
|
|
@ -1359,8 +1505,10 @@ function AgentConstellation({
|
|||
ref={canvasRef}
|
||||
className="agentSmokeCanvas"
|
||||
aria-hidden="true"
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
onPointerUp={handlePointerUp}
|
||||
onClick={handleCanvasClick}
|
||||
/>
|
||||
<canvas
|
||||
|
|
|
|||
|
|
@ -22,10 +22,38 @@ export interface AquariumAgentFrame {
|
|||
options: AquariumOptionFrame[];
|
||||
}
|
||||
|
||||
export interface AquariumUiButtonFrame {
|
||||
key: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
tone?: string;
|
||||
}
|
||||
|
||||
export interface AquariumUiStatusFrame {
|
||||
label: string;
|
||||
tone?: string;
|
||||
}
|
||||
|
||||
export interface AquariumUiFrame {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
reason: string;
|
||||
activeDeckLabel: string;
|
||||
activeSubdeck: string;
|
||||
statuses: AquariumUiStatusFrame[];
|
||||
deckButtons: AquariumUiButtonFrame[];
|
||||
subdeckButtons: AquariumUiButtonFrame[];
|
||||
actionButtons: AquariumUiButtonFrame[];
|
||||
panelTitle: string;
|
||||
panelLines: string[];
|
||||
alert?: string;
|
||||
}
|
||||
|
||||
export interface AquariumFrame {
|
||||
agents: AquariumAgentFrame[];
|
||||
selectedAgentId: string;
|
||||
activeLabel?: string;
|
||||
ui?: AquariumUiFrame;
|
||||
variant: "band" | "fullscreen";
|
||||
}
|
||||
|
||||
|
|
@ -34,6 +62,8 @@ export interface AquariumRenderer {
|
|||
dispose(): void;
|
||||
pickAgent(): string | null;
|
||||
pickOption(): string | null;
|
||||
pointerDownClient(clientX: number, clientY: number): void;
|
||||
pointerUp(): void;
|
||||
setFrame(frame: AquariumFrame): void;
|
||||
setPointerClient(clientX: number, clientY: number): void;
|
||||
}
|
||||
|
|
@ -68,6 +98,37 @@ interface HotZone {
|
|||
y: number;
|
||||
radius: number;
|
||||
key: string;
|
||||
height?: number;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
type FluidParamKey =
|
||||
| "timeScale"
|
||||
| "curlStrength"
|
||||
| "swirlForce"
|
||||
| "splatForce"
|
||||
| "splatRadius"
|
||||
| "velocityDissipation"
|
||||
| "dyeDissipation"
|
||||
| "injectionGain"
|
||||
| "sourceOpacity";
|
||||
|
||||
type FluidParams = Record<FluidParamKey, number>;
|
||||
|
||||
interface FluidParamDefinition {
|
||||
key: FluidParamKey;
|
||||
label: string;
|
||||
min: number;
|
||||
max: number;
|
||||
decimals: number;
|
||||
}
|
||||
|
||||
interface FluidParamZone {
|
||||
key: "toggle" | "reset" | FluidParamKey;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const fullscreenPositions: Record<string, { x: number; y: number }> = {
|
||||
|
|
@ -90,6 +151,32 @@ const compactPositions: Record<string, { x: number; y: number }> = {
|
|||
implementation: { x: 50, y: 61 },
|
||||
};
|
||||
|
||||
const fluidParamStorageKey = "epiphany:aquarium-fluid-params:v1";
|
||||
|
||||
const defaultFluidParams: FluidParams = {
|
||||
timeScale: 0.021,
|
||||
curlStrength: 24,
|
||||
swirlForce: 42,
|
||||
splatForce: 2.8,
|
||||
splatRadius: 42,
|
||||
velocityDissipation: 0.993,
|
||||
dyeDissipation: 0.9991,
|
||||
injectionGain: 0.055,
|
||||
sourceOpacity: 1,
|
||||
};
|
||||
|
||||
const fluidParamDefinitions: FluidParamDefinition[] = [
|
||||
{ key: "timeScale", label: "Time", min: 0.006, max: 0.05, decimals: 3 },
|
||||
{ key: "curlStrength", label: "Curl", min: 0, max: 48, decimals: 1 },
|
||||
{ key: "swirlForce", label: "Swirl", min: 0, max: 72, decimals: 1 },
|
||||
{ key: "splatForce", label: "Push", min: 0, max: 6, decimals: 2 },
|
||||
{ key: "splatRadius", label: "Ink Size", min: 16, max: 90, decimals: 0 },
|
||||
{ key: "velocityDissipation", label: "Motion Hold", min: 0.96, max: 0.999, decimals: 3 },
|
||||
{ key: "dyeDissipation", label: "Ink Hold", min: 0.985, max: 0.9998, decimals: 4 },
|
||||
{ key: "injectionGain", label: "Ink Feed", min: 0.01, max: 0.16, decimals: 3 },
|
||||
{ key: "sourceOpacity", label: "Object Ink", min: 0.2, max: 1.8, decimals: 2 },
|
||||
];
|
||||
|
||||
const vertexShader = `#version 300 es
|
||||
precision highp float;
|
||||
out vec2 vUv;
|
||||
|
|
@ -128,16 +215,20 @@ uniform vec4 uAgents[7];
|
|||
uniform float uActivity[7];
|
||||
uniform int uCount;
|
||||
uniform float uAspect;
|
||||
uniform float uSplatForce;
|
||||
uniform float uSplatRadius;
|
||||
uniform float uSwirlForce;
|
||||
uniform float uVelocityDamping;
|
||||
void main() {
|
||||
vec2 velocity = texture(uVelocity, vUv).xy * 0.994;
|
||||
vec2 velocity = texture(uVelocity, vUv).xy * uVelocityDamping;
|
||||
for (int i = 0; i < 7; i += 1) {
|
||||
if (i >= uCount) break;
|
||||
vec2 agent = uAgents[i].xy;
|
||||
vec2 delta = vUv - agent;
|
||||
delta.x *= uAspect;
|
||||
float influence = exp(-dot(delta, delta) * 42.0);
|
||||
vec2 tangent = normalize(vec2(-delta.y, delta.x) + 0.0001) * (28.0 + uActivity[i] * 42.0);
|
||||
vec2 push = uAgents[i].zw * 2.8 + tangent;
|
||||
float influence = exp(-dot(delta, delta) * uSplatRadius);
|
||||
vec2 tangent = normalize(vec2(-delta.y, delta.x) + 0.0001) * (uSwirlForce * 0.64 + uActivity[i] * uSwirlForce);
|
||||
vec2 push = uAgents[i].zw * uSplatForce + tangent;
|
||||
velocity += push * influence * (0.18 + uActivity[i] * 0.24);
|
||||
}
|
||||
outColor = vec4(velocity, 0.0, 1.0);
|
||||
|
|
@ -255,6 +346,34 @@ void main() {
|
|||
outColor = vec4(color * 0.92, 1.0);
|
||||
}`;
|
||||
|
||||
function loadFluidParams(): FluidParams {
|
||||
if (typeof window === "undefined") return { ...defaultFluidParams };
|
||||
try {
|
||||
const raw = window.localStorage.getItem(fluidParamStorageKey);
|
||||
if (!raw) return { ...defaultFluidParams };
|
||||
const parsed = JSON.parse(raw) as Partial<FluidParams>;
|
||||
return normalizeFluidParams(parsed);
|
||||
} catch {
|
||||
return { ...defaultFluidParams };
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeFluidParams(value: Partial<FluidParams>): FluidParams {
|
||||
const next = { ...defaultFluidParams };
|
||||
for (const definition of fluidParamDefinitions) {
|
||||
const candidate = value[definition.key];
|
||||
if (typeof candidate === "number" && Number.isFinite(candidate)) {
|
||||
next[definition.key] = clamp(candidate, definition.min, definition.max);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function saveFluidParams(params: FluidParams) {
|
||||
if (typeof window === "undefined") return;
|
||||
window.localStorage.setItem(fluidParamStorageKey, JSON.stringify(params));
|
||||
}
|
||||
|
||||
export function createAquariumRenderer(canvas: HTMLCanvasElement): AquariumRenderer;
|
||||
export function createAquariumRenderer(canvas: HTMLCanvasElement, crispCanvas: HTMLCanvasElement | null): AquariumRenderer;
|
||||
export function createAquariumRenderer(canvas: HTMLCanvasElement, crispCanvas: HTMLCanvasElement | null = null): AquariumRenderer {
|
||||
|
|
@ -270,7 +389,11 @@ class WebglAquariumRenderer implements AquariumRenderer {
|
|||
private agentsUniform = new Float32Array(7 * 4);
|
||||
private curl: FluidTarget | null = null;
|
||||
private dye: DoubleTarget | null = null;
|
||||
private draggingFluidParam: FluidParamKey | null = null;
|
||||
private divergence: FluidTarget | null = null;
|
||||
private fluidPanelPinned = false;
|
||||
private fluidParams = loadFluidParams();
|
||||
private fluidParamZones: FluidParamZone[] = [];
|
||||
private frame: AquariumFrame = { agents: [], selectedAgentId: "coordinator", variant: "fullscreen" };
|
||||
private hotAgents: HotZone[] = [];
|
||||
private hotOptions: HotZone[] = [];
|
||||
|
|
@ -284,6 +407,7 @@ class WebglAquariumRenderer implements AquariumRenderer {
|
|||
private sourceCanvas = document.createElement("canvas");
|
||||
private sourceContext: CanvasRenderingContext2D;
|
||||
private sourceTexture: WebGLTexture | null = null;
|
||||
private time = 0;
|
||||
private velocity: DoubleTarget | null = null;
|
||||
|
||||
private crispContext: CanvasRenderingContext2D | null = null;
|
||||
|
|
@ -318,12 +442,15 @@ class WebglAquariumRenderer implements AquariumRenderer {
|
|||
}
|
||||
|
||||
pickAgent() {
|
||||
const hit = this.hotAgents.find((zone) => distance(zone.x, zone.y, this.pointer.x, this.pointer.y) <= zone.radius);
|
||||
const hit = this.hotAgents.find((zone) => hitZone(zone, this.pointer.x, this.pointer.y));
|
||||
return hit?.key ?? null;
|
||||
}
|
||||
|
||||
pickOption() {
|
||||
const hit = this.hotOptions.find((zone) => distance(zone.x, zone.y, this.pointer.x, this.pointer.y) <= zone.radius);
|
||||
if (this.fluidParamZones.some((zone) => pointInRect(this.pointer.x, this.pointer.y, zone))) {
|
||||
return null;
|
||||
}
|
||||
const hit = this.hotOptions.find((zone) => hitZone(zone, this.pointer.x, this.pointer.y));
|
||||
return hit?.key ?? null;
|
||||
}
|
||||
|
||||
|
|
@ -336,6 +463,30 @@ class WebglAquariumRenderer implements AquariumRenderer {
|
|||
const x = ((clientX - rect.left) / Math.max(rect.width, 1)) * this.simWidth;
|
||||
const y = ((clientY - rect.top) / Math.max(rect.height, 1)) * this.simHeight;
|
||||
this.pointer = { active: true, x, y };
|
||||
if (this.draggingFluidParam) {
|
||||
this.updateFluidParamFromPointer(this.draggingFluidParam, x);
|
||||
}
|
||||
}
|
||||
|
||||
pointerDownClient(clientX: number, clientY: number) {
|
||||
this.setPointerClient(clientX, clientY);
|
||||
const zone = this.fluidParamZones.find((candidate) => pointInRect(this.pointer.x, this.pointer.y, candidate));
|
||||
if (!zone) return;
|
||||
if (zone.key === "toggle") {
|
||||
this.fluidPanelPinned = !this.fluidPanelPinned;
|
||||
return;
|
||||
}
|
||||
if (zone.key === "reset") {
|
||||
this.fluidParams = { ...defaultFluidParams };
|
||||
saveFluidParams(this.fluidParams);
|
||||
return;
|
||||
}
|
||||
this.draggingFluidParam = zone.key;
|
||||
this.updateFluidParamFromPointer(zone.key, this.pointer.x);
|
||||
}
|
||||
|
||||
pointerUp() {
|
||||
this.draggingFluidParam = null;
|
||||
}
|
||||
|
||||
private render = (millis: number) => {
|
||||
|
|
@ -345,13 +496,14 @@ class WebglAquariumRenderer implements AquariumRenderer {
|
|||
return;
|
||||
}
|
||||
const time = millis / 1000;
|
||||
this.time = time;
|
||||
const projected = this.projectAgents(time);
|
||||
const activeAgent = this.nearestAgent(projected) ?? projected.find((agent) => agent.id === this.frame.selectedAgentId) ?? projected[0];
|
||||
this.drawSource(projected, activeAgent);
|
||||
this.drawSource(projected, activeAgent, time);
|
||||
this.uploadSource();
|
||||
this.stepFluid(projected);
|
||||
this.display();
|
||||
this.drawCrispOverlay(projected, activeAgent);
|
||||
this.drawCrispOverlay(projected, activeAgent, time);
|
||||
this.raf = requestAnimationFrame(this.render);
|
||||
};
|
||||
|
||||
|
|
@ -453,8 +605,8 @@ class WebglAquariumRenderer implements AquariumRenderer {
|
|||
const pointerPull = this.pointer.active ? this.pointerPull(agent, state.x, state.y) : { x: 0, y: 0 };
|
||||
const swim = this.frame.variant === "fullscreen" ? 22 + activity * 76 : 8 + activity * 22;
|
||||
const cadence = 0.13 + activity * 0.28;
|
||||
const targetX = base.x + Math.sin(time * cadence + agent.phase * 1.7) * swim + pointerPull.x;
|
||||
const targetY = base.y + Math.cos(time * (cadence * 0.82) + agent.phase) * swim * 0.64 + pointerPull.y;
|
||||
const targetX = base.x + chirplet(time, agent.phase * 1.7, cadence, 0.018 + activity * 0.026, 9) * swim + pointerPull.x;
|
||||
const targetY = base.y + chirplet(time, agent.phase + 1.1, cadence * 0.82, -0.014 - activity * 0.018, 8) * swim * 0.64 + pointerPull.y;
|
||||
const follow = 0.0032 + activity * 0.0065;
|
||||
state.vx = state.vx * 0.94 + (targetX - state.x) * follow;
|
||||
state.vy = state.vy * 0.94 + (targetY - state.y) * follow;
|
||||
|
|
@ -500,7 +652,7 @@ class WebglAquariumRenderer implements AquariumRenderer {
|
|||
return best;
|
||||
}
|
||||
|
||||
private drawSource(projected: ProjectedAgent[], activeAgent?: ProjectedAgent) {
|
||||
private drawSource(projected: ProjectedAgent[], activeAgent: ProjectedAgent | undefined, time: number) {
|
||||
const ctx = this.sourceContext;
|
||||
ctx.clearRect(0, 0, this.simWidth, this.simHeight);
|
||||
this.hotOptions = [];
|
||||
|
|
@ -515,11 +667,13 @@ class WebglAquariumRenderer implements AquariumRenderer {
|
|||
this.drawOptionsSource(ctx, activeAgent);
|
||||
}
|
||||
this.drawDeckSource(ctx);
|
||||
this.drawOperatorSource(ctx, time);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
private drawAgentSource(ctx: CanvasRenderingContext2D, agent: ProjectedAgent, hot: boolean) {
|
||||
const size = 23 + agent.activity * 12 + (hot ? 4 : 0);
|
||||
const ink = this.fluidParams.sourceOpacity;
|
||||
ctx.save();
|
||||
ctx.translate(agent.x, agent.y);
|
||||
ctx.rotate(Math.atan2(agent.vy, agent.vx || 0.001) * 0.12);
|
||||
|
|
@ -527,13 +681,13 @@ class WebglAquariumRenderer implements AquariumRenderer {
|
|||
for (let index = 0; index < 3; index += 1) {
|
||||
const lag = 11 + index * 9;
|
||||
const radius = size * (0.42 - index * 0.06);
|
||||
ctx.globalAlpha = 0.075 + agent.activity * 0.045 - index * 0.014;
|
||||
ctx.globalAlpha = (0.075 + agent.activity * 0.045 - index * 0.014) * ink;
|
||||
ctx.fillStyle = index === 0 ? agent.color : agent.glow;
|
||||
ctx.beginPath();
|
||||
ctx.arc(-agent.vx * lag - index * 3, -agent.vy * lag + index * 2, radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.globalAlpha = 0.04 + agent.activity * 0.024;
|
||||
ctx.globalAlpha = (0.04 + agent.activity * 0.024) * ink;
|
||||
ctx.strokeStyle = agent.glow;
|
||||
ctx.lineWidth = 1.8 + agent.activity * 1.2;
|
||||
ctx.beginPath();
|
||||
|
|
@ -541,18 +695,18 @@ class WebglAquariumRenderer implements AquariumRenderer {
|
|||
ctx.quadraticCurveTo(-agent.vx * 16, -agent.vy * 8, -agent.vx * 24, -agent.vy * 18);
|
||||
ctx.stroke();
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
ctx.globalAlpha = 0.025 + agent.activity * 0.018 + (hot ? 0.016 : 0);
|
||||
ctx.globalAlpha = (0.025 + agent.activity * 0.018 + (hot ? 0.016 : 0)) * ink;
|
||||
ctx.shadowColor = agent.glow;
|
||||
ctx.shadowBlur = 8 + agent.activity * 10;
|
||||
ctx.fillStyle = agent.color;
|
||||
drawAgentPath(ctx, agent.shape, size);
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.globalAlpha = hot ? 0.09 : 0.045;
|
||||
ctx.globalAlpha = (hot ? 0.09 : 0.045) * ink;
|
||||
ctx.strokeStyle = "#ffffff";
|
||||
ctx.lineWidth = hot ? 1.8 : 1;
|
||||
ctx.stroke();
|
||||
ctx.globalAlpha = hot ? 0.095 : 0.042;
|
||||
ctx.globalAlpha = (hot ? 0.095 : 0.042) * ink;
|
||||
ctx.fillStyle = "#fffaf0";
|
||||
ctx.font = `900 ${Math.max(10, size * 0.42)}px Inter, system-ui, sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
|
|
@ -561,7 +715,7 @@ class WebglAquariumRenderer implements AquariumRenderer {
|
|||
ctx.restore();
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = hot ? 0.038 : 0.018;
|
||||
ctx.globalAlpha = (hot ? 0.038 : 0.018) * ink;
|
||||
ctx.fillStyle = agent.color;
|
||||
ctx.strokeStyle = hexAlpha(agent.glow, hot ? 0.5 : 0.26);
|
||||
roundedRect(ctx, agent.x - 40, agent.y + size * 0.72, 80, 34, 7);
|
||||
|
|
@ -581,7 +735,7 @@ class WebglAquariumRenderer implements AquariumRenderer {
|
|||
const x = clamp(agent.x + (agent.x > this.simWidth * 0.68 ? -boxWidth - 36 : 36), 14, this.simWidth - boxWidth - 14);
|
||||
const y = clamp(agent.y - 78, 12, this.simHeight - 116);
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.018;
|
||||
ctx.globalAlpha = 0.018 * this.fluidParams.sourceOpacity;
|
||||
ctx.strokeStyle = agent.glow;
|
||||
ctx.shadowColor = agent.glow;
|
||||
ctx.shadowBlur = 5;
|
||||
|
|
@ -610,7 +764,7 @@ class WebglAquariumRenderer implements AquariumRenderer {
|
|||
if (!option.disabled) {
|
||||
this.hotOptions.push({ x, y, radius: 32, key: option.key });
|
||||
}
|
||||
ctx.globalAlpha = option.disabled ? 0.008 : hot ? 0.04 : 0.018;
|
||||
ctx.globalAlpha = (option.disabled ? 0.008 : hot ? 0.04 : 0.018) * this.fluidParams.sourceOpacity;
|
||||
ctx.fillStyle = option.disabled ? agent.glow : agent.color;
|
||||
ctx.strokeStyle = option.disabled ? hexAlpha(agent.glow, 0.12) : hexAlpha(agent.glow, hot ? 0.62 : 0.32);
|
||||
roundedRect(ctx, x - 37, y - 15, 74, 30, 15);
|
||||
|
|
@ -623,7 +777,7 @@ class WebglAquariumRenderer implements AquariumRenderer {
|
|||
private drawDeckSource(ctx: CanvasRenderingContext2D) {
|
||||
if (!this.frame.activeLabel) return;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.005;
|
||||
ctx.globalAlpha = 0.005 * this.fluidParams.sourceOpacity;
|
||||
ctx.fillStyle = "#f7bd58";
|
||||
ctx.font = `900 ${Math.max(30, Math.min(this.simWidth, this.simHeight) * 0.1)}px Inter, system-ui, sans-serif`;
|
||||
ctx.textAlign = "right";
|
||||
|
|
@ -632,6 +786,29 @@ class WebglAquariumRenderer implements AquariumRenderer {
|
|||
ctx.restore();
|
||||
}
|
||||
|
||||
private drawOperatorSource(ctx: CanvasRenderingContext2D, time: number) {
|
||||
if (!this.frame.ui) return;
|
||||
const layout = this.operatorLayout(time);
|
||||
const ink = this.fluidParams.sourceOpacity;
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = "lighter";
|
||||
ctx.strokeStyle = "rgba(247, 189, 88, 0.72)";
|
||||
ctx.fillStyle = "rgba(247, 189, 88, 0.22)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.globalAlpha = 0.012 * ink;
|
||||
roundedRect(ctx, layout.topbar.x, layout.topbar.y, layout.topbar.width, layout.topbar.height, 10);
|
||||
ctx.stroke();
|
||||
ctx.globalAlpha = 0.01 * ink;
|
||||
roundedRect(ctx, layout.panel.x, layout.panel.y, layout.panel.width, layout.panel.height, 10);
|
||||
ctx.stroke();
|
||||
for (const button of [...layout.deckButtons, ...layout.subdeckButtons, ...layout.actionButtons]) {
|
||||
ctx.globalAlpha = button.disabled ? 0.004 * ink : 0.011 * ink;
|
||||
roundedRect(ctx, button.x, button.y, button.width, button.height, button.height / 2);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
private uploadSource() {
|
||||
const gl = this.gl;
|
||||
if (!this.sourceTexture) return;
|
||||
|
|
@ -643,8 +820,8 @@ class WebglAquariumRenderer implements AquariumRenderer {
|
|||
|
||||
private stepFluid(projected: ProjectedAgent[]) {
|
||||
if (!this.velocity || !this.dye || !this.pressure || !this.divergence || !this.curl || !this.sourceTexture) return;
|
||||
const dt = 0.021;
|
||||
this.runAdvect(this.velocity.read.texture, this.velocity.read.texture, this.velocity.write, dt, 0.993);
|
||||
const dt = this.fluidParams.timeScale;
|
||||
this.runAdvect(this.velocity.read.texture, this.velocity.read.texture, this.velocity.write, dt, this.fluidParams.velocityDissipation);
|
||||
this.velocity.swap();
|
||||
this.runVelocitySplat(projected);
|
||||
this.runCurl();
|
||||
|
|
@ -652,7 +829,7 @@ class WebglAquariumRenderer implements AquariumRenderer {
|
|||
this.runDivergence();
|
||||
this.runPressure();
|
||||
this.runGradientSubtract();
|
||||
this.runAdvect(this.velocity.read.texture, this.dye.read.texture, this.dye.write, dt, 0.9991);
|
||||
this.runAdvect(this.velocity.read.texture, this.dye.read.texture, this.dye.write, dt, this.fluidParams.dyeDissipation);
|
||||
this.dye.swap();
|
||||
this.runInject();
|
||||
}
|
||||
|
|
@ -688,6 +865,10 @@ class WebglAquariumRenderer implements AquariumRenderer {
|
|||
gl.uniform1fv(gl.getUniformLocation(program, "uActivity"), this.activity);
|
||||
gl.uniform1i(gl.getUniformLocation(program, "uCount"), Math.min(7, projected.length));
|
||||
gl.uniform1f(gl.getUniformLocation(program, "uAspect"), this.simWidth / Math.max(this.simHeight, 1));
|
||||
gl.uniform1f(gl.getUniformLocation(program, "uSplatForce"), this.fluidParams.splatForce);
|
||||
gl.uniform1f(gl.getUniformLocation(program, "uSplatRadius"), this.fluidParams.splatRadius);
|
||||
gl.uniform1f(gl.getUniformLocation(program, "uSwirlForce"), this.fluidParams.swirlForce);
|
||||
gl.uniform1f(gl.getUniformLocation(program, "uVelocityDamping"), this.fluidParams.velocityDissipation);
|
||||
});
|
||||
this.velocity.swap();
|
||||
}
|
||||
|
|
@ -709,7 +890,7 @@ class WebglAquariumRenderer implements AquariumRenderer {
|
|||
gl.uniform1i(gl.getUniformLocation(program, "uVelocity"), 0);
|
||||
gl.uniform1i(gl.getUniformLocation(program, "uCurl"), 1);
|
||||
gl.uniform2f(gl.getUniformLocation(program, "uTexelSize"), 1 / this.simWidth, 1 / this.simHeight);
|
||||
gl.uniform1f(gl.getUniformLocation(program, "uCurlStrength"), 24.0);
|
||||
gl.uniform1f(gl.getUniformLocation(program, "uCurlStrength"), this.fluidParams.curlStrength);
|
||||
gl.uniform1f(gl.getUniformLocation(program, "uDt"), dt);
|
||||
});
|
||||
this.velocity.swap();
|
||||
|
|
@ -757,8 +938,8 @@ class WebglAquariumRenderer implements AquariumRenderer {
|
|||
this.bindTexture(1, this.sourceTexture);
|
||||
gl.uniform1i(gl.getUniformLocation(program, "uDye"), 0);
|
||||
gl.uniform1i(gl.getUniformLocation(program, "uSource"), 1);
|
||||
gl.uniform1f(gl.getUniformLocation(program, "uGain"), 0.055);
|
||||
gl.uniform1f(gl.getUniformLocation(program, "uDissipation"), 0.9988);
|
||||
gl.uniform1f(gl.getUniformLocation(program, "uGain"), this.fluidParams.injectionGain);
|
||||
gl.uniform1f(gl.getUniformLocation(program, "uDissipation"), this.fluidParams.dyeDissipation);
|
||||
});
|
||||
this.dye.swap();
|
||||
}
|
||||
|
|
@ -774,7 +955,7 @@ class WebglAquariumRenderer implements AquariumRenderer {
|
|||
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
||||
}
|
||||
|
||||
private drawCrispOverlay(projected: ProjectedAgent[], activeAgent?: ProjectedAgent) {
|
||||
private drawCrispOverlay(projected: ProjectedAgent[], activeAgent: ProjectedAgent | undefined, time: number) {
|
||||
if (!this.crispCanvas || !this.crispContext || !this.simWidth || !this.simHeight) return;
|
||||
const ctx = this.crispContext;
|
||||
const scaleX = this.crispCanvas.width / this.simWidth;
|
||||
|
|
@ -792,6 +973,186 @@ class WebglAquariumRenderer implements AquariumRenderer {
|
|||
this.drawCrispOptions(ctx, activeAgent);
|
||||
}
|
||||
this.drawCrispDeck(ctx);
|
||||
this.drawOperatorUi(ctx, time);
|
||||
this.drawFluidPanel(ctx, time);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
private operatorLayout(time: number) {
|
||||
const mobile = this.simWidth < 430;
|
||||
const wobble = chirplet(time, 0.9, 1.2, 0.08, 4);
|
||||
const topbar = {
|
||||
x: mobile ? 10 : 14,
|
||||
y: 12 + wobble * 1.8,
|
||||
width: this.simWidth - (mobile ? 20 : 28),
|
||||
height: mobile ? 154 : 68,
|
||||
};
|
||||
const deckWidth = mobile ? this.simWidth - 20 : 78;
|
||||
const deckX = mobile ? 10 : 14;
|
||||
const deckY = mobile ? topbar.y + topbar.height + 8 : topbar.y + topbar.height + 22;
|
||||
const deckButtons = (this.frame.ui?.deckButtons ?? []).map((button, index) => ({
|
||||
...button,
|
||||
x: mobile ? deckX + index * ((deckWidth - 6) / 4) : deckX,
|
||||
y: mobile ? deckY : deckY + index * 46,
|
||||
width: mobile ? (deckWidth - 18) / 4 : deckWidth,
|
||||
height: mobile ? 36 : 38,
|
||||
}));
|
||||
const panel = {
|
||||
x: mobile ? 10 : deckX + deckWidth + 14,
|
||||
y: mobile ? deckY + 48 : deckY,
|
||||
width: mobile ? this.simWidth - 20 : Math.min(360, this.simWidth * 0.48),
|
||||
height: mobile ? Math.min(370, this.simHeight - deckY - 60) : Math.min(384, this.simHeight - deckY - 22),
|
||||
};
|
||||
let subdeckX = panel.x + 16;
|
||||
const subdeckButtons = (this.frame.ui?.subdeckButtons ?? []).map((button) => {
|
||||
const width = Math.min(98, Math.max(62, button.label.length * 6.2 + 22));
|
||||
const positioned = {
|
||||
...button,
|
||||
x: subdeckX,
|
||||
y: panel.y + 48,
|
||||
width,
|
||||
height: 24,
|
||||
};
|
||||
subdeckX += width + 8;
|
||||
return positioned;
|
||||
});
|
||||
const actionButtons = (this.frame.ui?.actionButtons ?? []).map((button, index) => {
|
||||
const columns = mobile ? 1 : 2;
|
||||
const gap = 8;
|
||||
const width = (panel.width - 32 - gap * (columns - 1)) / columns;
|
||||
const x = panel.x + 16 + (index % columns) * (width + gap);
|
||||
const y = panel.y + 92 + Math.floor(index / columns) * 32;
|
||||
return { ...button, x, y, width, height: 26 };
|
||||
});
|
||||
return { actionButtons, deckButtons, panel, subdeckButtons, topbar };
|
||||
}
|
||||
|
||||
private drawOperatorUi(ctx: CanvasRenderingContext2D, time: number) {
|
||||
const ui = this.frame.ui;
|
||||
if (!ui) return;
|
||||
const layout = this.operatorLayout(time);
|
||||
const bounce = chirplet(time, 2.7, 1.4, -0.06, 5);
|
||||
const mobile = this.simWidth < 430;
|
||||
ctx.save();
|
||||
ctx.textBaseline = "top";
|
||||
ctx.lineJoin = "round";
|
||||
|
||||
ctx.globalAlpha = mobile ? 0.96 : 0.82;
|
||||
ctx.fillStyle = mobile ? "rgba(5, 12, 9, 0.9)" : "rgba(5, 12, 9, 0.72)";
|
||||
ctx.strokeStyle = "rgba(219, 238, 216, 0.34)";
|
||||
ctx.shadowColor = "rgba(146, 216, 118, 0.28)";
|
||||
ctx.shadowBlur = 16;
|
||||
roundedRect(ctx, layout.topbar.x, layout.topbar.y, layout.topbar.width, layout.topbar.height, 10);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.fillStyle = "#b9d8b5";
|
||||
ctx.font = "900 8px Inter, system-ui, sans-serif";
|
||||
ctx.fillText(ui.eyebrow.toUpperCase(), layout.topbar.x + 14, layout.topbar.y + 10);
|
||||
ctx.fillStyle = "#fbfff8";
|
||||
ctx.font = `${mobile ? 20 : 24}px Inter, system-ui, sans-serif`;
|
||||
ctx.fillText(ui.title, layout.topbar.x + 14, layout.topbar.y + 24);
|
||||
ctx.fillStyle = "rgba(226, 245, 225, 0.78)";
|
||||
ctx.font = "800 9px Inter, system-ui, sans-serif";
|
||||
wrapCanvasText(ctx, ui.reason, layout.topbar.x + 14, layout.topbar.y + (mobile ? 51 : 51), mobile ? layout.topbar.width - 28 : layout.topbar.width * 0.58, 12, mobile ? 2 : 1);
|
||||
|
||||
const pillY = mobile ? layout.topbar.y + 92 : layout.topbar.y + 14;
|
||||
ui.statuses.slice(0, mobile ? 2 : 3).forEach((status, index) => {
|
||||
const width = mobile ? layout.topbar.width - 28 : Math.min(112, Math.max(62, status.label.length * 5.8 + 20));
|
||||
const x = mobile
|
||||
? layout.topbar.x + 14
|
||||
: layout.topbar.x + layout.topbar.width - width - 12 - index * (width + 8);
|
||||
const y = mobile ? pillY + index * 26 : pillY;
|
||||
this.drawCrispButton(ctx, { key: "", label: status.label, disabled: false, tone: status.tone, x, y, width, height: 24 }, false, time + index);
|
||||
});
|
||||
|
||||
for (const [index, button] of layout.deckButtons.entries()) {
|
||||
const hot = this.pointer.active && hitZone({ ...button, radius: 0, key: button.key }, this.pointer.x, this.pointer.y);
|
||||
this.hotOptions.push({ x: button.x, y: button.y, width: button.width, height: button.height, radius: 0, key: button.key });
|
||||
this.drawCrispButton(ctx, button, hot, time + index * 0.7);
|
||||
}
|
||||
|
||||
ctx.globalAlpha = mobile ? 0.94 : 0.82;
|
||||
ctx.fillStyle = mobile ? "rgba(5, 12, 9, 0.88)" : "rgba(5, 12, 9, 0.76)";
|
||||
ctx.strokeStyle = "rgba(219, 238, 216, 0.32)";
|
||||
ctx.shadowColor = "rgba(88, 221, 196, 0.18)";
|
||||
ctx.shadowBlur = 18;
|
||||
roundedRect(ctx, layout.panel.x, layout.panel.y + bounce * 2.4, layout.panel.width, layout.panel.height, 10);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
ctx.fillStyle = "#b9d8b5";
|
||||
ctx.font = "900 8px Inter, system-ui, sans-serif";
|
||||
ctx.fillText(ui.activeDeckLabel.toUpperCase(), layout.panel.x + 16, layout.panel.y + 15 + bounce * 2.4);
|
||||
ctx.fillStyle = "#fbfff8";
|
||||
ctx.font = "900 16px Inter, system-ui, sans-serif";
|
||||
ctx.fillText(ui.activeSubdeck, layout.panel.x + 16, layout.panel.y + 28 + bounce * 2.4);
|
||||
|
||||
for (const [index, button] of layout.subdeckButtons.entries()) {
|
||||
const hot = this.pointer.active && hitZone({ ...button, radius: 0, key: button.key }, this.pointer.x, this.pointer.y);
|
||||
this.hotOptions.push({ x: button.x, y: button.y, width: button.width, height: button.height, radius: 0, key: button.key });
|
||||
this.drawCrispButton(ctx, button, hot, time + index * 0.45);
|
||||
}
|
||||
|
||||
if (layout.actionButtons.length > 0) {
|
||||
for (const [index, button] of layout.actionButtons.entries()) {
|
||||
const hot = this.pointer.active && hitZone({ ...button, radius: 0, key: button.key }, this.pointer.x, this.pointer.y);
|
||||
this.hotOptions.push({ x: button.x, y: button.y, width: button.width, height: button.height, radius: 0, key: button.key });
|
||||
this.drawCrispButton(ctx, button, hot, time + index * 0.32);
|
||||
}
|
||||
} else {
|
||||
ctx.fillStyle = "rgba(226, 245, 225, 0.8)";
|
||||
ctx.font = "800 10px Inter, system-ui, sans-serif";
|
||||
ui.panelLines.slice(0, 10).forEach((line, index) => {
|
||||
wrapCanvasText(ctx, line, layout.panel.x + 16, layout.panel.y + 88 + index * 25, layout.panel.width - 32, 13, 2);
|
||||
});
|
||||
}
|
||||
|
||||
if (layout.actionButtons.length > 0 && ui.panelLines.length > 0) {
|
||||
ctx.fillStyle = "rgba(226, 245, 225, 0.72)";
|
||||
ctx.font = "800 9px Inter, system-ui, sans-serif";
|
||||
const startY = layout.panel.y + 92 + Math.ceil(layout.actionButtons.length / (this.simWidth < 430 ? 1 : 2)) * 32 + 8;
|
||||
ui.panelLines.slice(0, 3).forEach((line, index) => {
|
||||
wrapCanvasText(ctx, line, layout.panel.x + 16, startY + index * 18, layout.panel.width - 32, 12, 1);
|
||||
});
|
||||
}
|
||||
|
||||
if (ui.alert && !mobile) {
|
||||
ctx.globalAlpha = 0.84;
|
||||
ctx.fillStyle = "rgba(255, 244, 203, 0.86)";
|
||||
ctx.strokeStyle = "rgba(247, 189, 88, 0.6)";
|
||||
const alertWidth = Math.min(300, layout.topbar.width * 0.42);
|
||||
const alertX = layout.topbar.x + layout.topbar.width - alertWidth - 12;
|
||||
const alertY = layout.topbar.y + layout.topbar.height + 10;
|
||||
roundedRect(ctx, alertX, alertY, alertWidth, 30, 7);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = "#70410f";
|
||||
ctx.font = "900 9px Inter, system-ui, sans-serif";
|
||||
wrapCanvasText(ctx, ui.alert, alertX + 10, alertY + 9, alertWidth - 20, 11, 1);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
private drawCrispButton(ctx: CanvasRenderingContext2D, button: AquariumUiButtonFrame & { x: number; y: number; width: number; height: number }, hot: boolean, time: number) {
|
||||
const pulse = chirplet(time, 0.5, 1.8, 0.09, 3);
|
||||
const disabled = Boolean(button.disabled);
|
||||
const toneColor = toneColorFor(button.tone);
|
||||
ctx.save();
|
||||
ctx.translate(0, hot && !disabled ? pulse * 1.8 : 0);
|
||||
ctx.globalAlpha = disabled ? 0.36 : hot ? 0.94 : 0.78;
|
||||
ctx.fillStyle = hot && !disabled ? hexAlpha(toneColor, 0.32) : "rgba(8, 14, 12, 0.78)";
|
||||
ctx.strokeStyle = disabled ? "rgba(226, 245, 225, 0.16)" : hexAlpha(toneColor, hot ? 0.9 : 0.5);
|
||||
roundedRect(ctx, button.x, button.y, button.width, button.height, Math.min(16, button.height / 2));
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = disabled ? "rgba(236, 246, 235, 0.42)" : "#fbfff8";
|
||||
ctx.font = `900 ${button.width < 70 ? 7 : 9}px Inter, system-ui, sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(button.label.toUpperCase().slice(0, button.width < 70 ? 9 : 18), button.x + button.width / 2, button.y + button.height / 2 + 0.5);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
|
|
@ -899,6 +1260,114 @@ class WebglAquariumRenderer implements AquariumRenderer {
|
|||
ctx.restore();
|
||||
}
|
||||
|
||||
private drawFluidPanel(ctx: CanvasRenderingContext2D, time: number) {
|
||||
this.fluidParamZones = [];
|
||||
const iconSize = 32;
|
||||
const iconX = this.simWidth - iconSize - 16;
|
||||
const iconY = this.simHeight - iconSize - 16;
|
||||
const iconZone: FluidParamZone = { key: "toggle", x: iconX, y: iconY, width: iconSize, height: iconSize };
|
||||
this.fluidParamZones.push(iconZone);
|
||||
const nearIcon = this.pointer.active && pointInInflatedRect(this.pointer.x, this.pointer.y, iconZone, 54);
|
||||
const open = this.fluidPanelPinned || nearIcon || this.draggingFluidParam !== null;
|
||||
const pulse = chirplet(time, 1.8, 2.4, 0.18, 2.8);
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = open ? 0.94 : 0.66;
|
||||
ctx.fillStyle = open ? "rgba(247, 189, 88, 0.24)" : "rgba(8, 14, 12, 0.76)";
|
||||
ctx.strokeStyle = "rgba(247, 189, 88, 0.76)";
|
||||
ctx.shadowColor = "rgba(247, 189, 88, 0.42)";
|
||||
ctx.shadowBlur = open ? 18 : 10;
|
||||
roundedRect(ctx, iconX + pulse * 1.2, iconY - pulse * 1.2, iconSize, iconSize, 11);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.fillStyle = "#fff4cb";
|
||||
ctx.font = "900 16px Inter, system-ui, sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText("≈", iconX + iconSize / 2 + pulse * 1.2, iconY + iconSize / 2 - pulse * 1.2);
|
||||
|
||||
if (!open) {
|
||||
ctx.restore();
|
||||
return;
|
||||
}
|
||||
|
||||
const panelWidth = Math.min(292, this.simWidth - 24);
|
||||
const panelHeight = Math.min(342, this.simHeight - 48);
|
||||
const panelX = clamp(iconX - panelWidth + iconSize, 12, this.simWidth - panelWidth - 12);
|
||||
const panelY = clamp(iconY - panelHeight - 12 + chirplet(time, 0.2, 1.15, -0.05, 4) * 4, 12, this.simHeight - panelHeight - 12);
|
||||
ctx.globalAlpha = 0.9;
|
||||
ctx.fillStyle = "rgba(5, 12, 9, 0.82)";
|
||||
ctx.strokeStyle = "rgba(247, 189, 88, 0.48)";
|
||||
ctx.shadowColor = "rgba(88, 221, 196, 0.22)";
|
||||
ctx.shadowBlur = 20;
|
||||
roundedRect(ctx, panelX, panelY, panelWidth, panelHeight, 10);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.fillStyle = "#f7bd58";
|
||||
ctx.font = "900 9px Inter, system-ui, sans-serif";
|
||||
ctx.textAlign = "left";
|
||||
ctx.textBaseline = "top";
|
||||
ctx.fillText("FLUID PARAMETERS", panelX + 14, panelY + 12);
|
||||
ctx.fillStyle = "rgba(226, 245, 225, 0.76)";
|
||||
ctx.font = "800 9px Inter, system-ui, sans-serif";
|
||||
ctx.fillText(this.fluidPanelPinned ? "pinned; drag rails" : "hover; click icon to pin", panelX + 14, panelY + 27);
|
||||
|
||||
const railX = panelX + 104;
|
||||
const railWidth = panelWidth - 124;
|
||||
fluidParamDefinitions.forEach((definition, index) => {
|
||||
const y = panelY + 52 + index * 27;
|
||||
const value = this.fluidParams[definition.key];
|
||||
const t = (value - definition.min) / (definition.max - definition.min);
|
||||
const hot = this.pointer.active && this.pointer.x >= railX - 4 && this.pointer.x <= railX + railWidth + 4 && this.pointer.y >= y - 5 && this.pointer.y <= y + 13;
|
||||
this.fluidParamZones.push({ key: definition.key, x: railX - 5, y: y - 6, width: railWidth + 10, height: 18 });
|
||||
ctx.fillStyle = hot ? "#fbfff8" : "rgba(226, 245, 225, 0.82)";
|
||||
ctx.font = "900 8px Inter, system-ui, sans-serif";
|
||||
ctx.fillText(definition.label.toUpperCase(), panelX + 14, y - 4);
|
||||
ctx.fillStyle = "rgba(226, 245, 225, 0.42)";
|
||||
ctx.fillRect(railX, y + 3, railWidth, 3);
|
||||
ctx.fillStyle = hot ? "#58ddc4" : "#f7bd58";
|
||||
ctx.fillRect(railX, y + 3, railWidth * t, 3);
|
||||
ctx.beginPath();
|
||||
ctx.arc(railX + railWidth * t, y + 4.5 + chirplet(time, index, 2.1, 0.03, 3) * 0.8, hot ? 5 : 3.6, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = "rgba(251, 255, 248, 0.76)";
|
||||
ctx.font = "800 8px Inter, system-ui, sans-serif";
|
||||
ctx.textAlign = "right";
|
||||
ctx.fillText(value.toFixed(definition.decimals), panelX + panelWidth - 14, y - 4);
|
||||
ctx.textAlign = "left";
|
||||
});
|
||||
|
||||
const resetZone: FluidParamZone = { key: "reset", x: panelX + panelWidth - 82, y: panelY + panelHeight - 34, width: 66, height: 22 };
|
||||
this.fluidParamZones.push(resetZone);
|
||||
const resetHot = this.pointer.active && pointInRect(this.pointer.x, this.pointer.y, resetZone);
|
||||
ctx.globalAlpha = resetHot ? 0.94 : 0.72;
|
||||
ctx.fillStyle = resetHot ? "rgba(241, 95, 69, 0.26)" : "rgba(8, 14, 12, 0.74)";
|
||||
ctx.strokeStyle = "rgba(241, 95, 69, 0.56)";
|
||||
roundedRect(ctx, resetZone.x, resetZone.y, resetZone.width, resetZone.height, 11);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = "#fbfff8";
|
||||
ctx.font = "900 8px Inter, system-ui, sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText("RESET", resetZone.x + resetZone.width / 2, resetZone.y + resetZone.height / 2);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
private updateFluidParamFromPointer(key: FluidParamKey, pointerX: number) {
|
||||
const zone = this.fluidParamZones.find((candidate) => candidate.key === key);
|
||||
const definition = fluidParamDefinitions.find((candidate) => candidate.key === key);
|
||||
if (!zone || !definition) return;
|
||||
const t = clamp((pointerX - zone.x) / Math.max(zone.width, 1), 0, 1);
|
||||
this.fluidParams = {
|
||||
...this.fluidParams,
|
||||
[key]: definition.min + t * (definition.max - definition.min),
|
||||
};
|
||||
saveFluidParams(this.fluidParams);
|
||||
}
|
||||
|
||||
private drawTo(target: FluidTarget, program: WebGLProgram, uniforms: (gl: WebGL2RenderingContext, program: WebGLProgram) => void) {
|
||||
const gl = this.gl;
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, target.fbo);
|
||||
|
|
@ -935,7 +1404,7 @@ class CanvasAquariumRenderer implements AquariumRenderer {
|
|||
}
|
||||
|
||||
pickAgent() {
|
||||
return this.hotAgents.find((zone) => distance(zone.x, zone.y, this.pointer.x, this.pointer.y) <= zone.radius)?.key ?? null;
|
||||
return this.hotAgents.find((zone) => hitZone(zone, this.pointer.x, this.pointer.y))?.key ?? null;
|
||||
}
|
||||
|
||||
pickOption() {
|
||||
|
|
@ -955,6 +1424,14 @@ class CanvasAquariumRenderer implements AquariumRenderer {
|
|||
};
|
||||
}
|
||||
|
||||
pointerDownClient(clientX: number, clientY: number) {
|
||||
this.setPointerClient(clientX, clientY);
|
||||
}
|
||||
|
||||
pointerUp() {
|
||||
// The 2D fallback has no draggable fluid parameters.
|
||||
}
|
||||
|
||||
private render = (millis: number) => {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
|
||||
|
|
@ -1017,6 +1494,35 @@ function compileShader(gl: WebGL2RenderingContext, type: number, source: string)
|
|||
return shader;
|
||||
}
|
||||
|
||||
function hitZone(zone: HotZone, x: number, y: number) {
|
||||
if (zone.width !== undefined && zone.height !== undefined) {
|
||||
return x >= zone.x && x <= zone.x + zone.width && y >= zone.y && y <= zone.y + zone.height;
|
||||
}
|
||||
return distance(zone.x, zone.y, x, y) <= zone.radius;
|
||||
}
|
||||
|
||||
function pointInRect(x: number, y: number, rect: FluidParamZone) {
|
||||
return x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height;
|
||||
}
|
||||
|
||||
function pointInInflatedRect(x: number, y: number, rect: FluidParamZone, inflate: number) {
|
||||
return x >= rect.x - inflate && x <= rect.x + rect.width + inflate && y >= rect.y - inflate && y <= rect.y + rect.height + inflate;
|
||||
}
|
||||
|
||||
function chirplet(time: number, phase: number, frequency: number, chirp: number, period: number) {
|
||||
const local = ((time + phase) % period + period) % period;
|
||||
const centered = local - period / 2;
|
||||
const envelope = 0.28 + 0.72 * Math.exp(-(centered * centered) / (period * period * 0.18));
|
||||
return Math.sin(phase + frequency * local + chirp * local * local) * envelope;
|
||||
}
|
||||
|
||||
function toneColorFor(tone?: string) {
|
||||
if (tone === "danger") return "#f15f45";
|
||||
if (tone === "warn") return "#f7bd58";
|
||||
if (tone === "ok") return "#58ddc4";
|
||||
return "#92d876";
|
||||
}
|
||||
|
||||
function drawAgentPath(ctx: CanvasRenderingContext2D, shape: string, size: number) {
|
||||
const r = size / 2;
|
||||
ctx.beginPath();
|
||||
|
|
|
|||
|
|
@ -472,6 +472,22 @@ body:has(.immersiveShell) {
|
|||
mask-image: radial-gradient(circle at 50% 50%, rgba(0, 0, 0, 0.8), transparent 72%);
|
||||
}
|
||||
|
||||
.immersiveShell .hudGrid,
|
||||
.immersiveShell .immersiveTopbar,
|
||||
.immersiveShell .deckRail,
|
||||
.immersiveShell .diegeticPanel,
|
||||
.immersiveShell .hudToastStack {
|
||||
position: absolute !important;
|
||||
width: 1px !important;
|
||||
height: 1px !important;
|
||||
margin: -1px !important;
|
||||
overflow: hidden !important;
|
||||
clip: rect(0 0 0 0) !important;
|
||||
clip-path: inset(50%) !important;
|
||||
opacity: 0 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.immersiveTopbar,
|
||||
.deckRail,
|
||||
.diegeticPanel,
|
||||
|
|
|
|||
Reference in a new issue